"""Playbook endpoints for triggering infrastructure automation.""" import re import uuid import httpx from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from app.db import AsyncSessionDep from app.models.agent import Agent from app.models.task import Task from app.models.tenant import Tenant from app.playbooks.chatwoot import create_chatwoot_setup_task from app.playbooks.nextcloud import ( create_nextcloud_initial_setup_task, create_nextcloud_set_domain_task, ) from app.schemas.task import TaskResponse router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"]) # Health check timeout for Nextcloud availability NEXTCLOUD_HEALTH_CHECK_TIMEOUT = 10.0 class ChatwootSetupRequest(BaseModel): """Request body for Chatwoot setup playbook.""" agent_id: uuid.UUID | None = Field( None, description="Optional agent UUID to assign the task to" ) domain: str = Field( ..., min_length=1, description="Domain for Chatwoot (e.g., support.example.com)" ) class NextcloudSetDomainRequest(BaseModel): """Request body for Nextcloud set-domain playbook.""" public_url: str = Field( ..., min_length=1, description="Public URL for Nextcloud (e.g., https://cloud.example.com)", ) pull: bool = Field( False, description="Whether to pull images before reloading the stack", ) class NextcloudInitialSetupRequest(BaseModel): """Request body for Nextcloud initial setup via Playwright.""" admin_username: str = Field( ..., min_length=3, max_length=64, description="Username for the Nextcloud admin account", ) admin_password: str = Field( ..., min_length=8, description="Password for the Nextcloud admin account (minimum 8 characters)", ) @field_validator("admin_username") @classmethod def validate_username(cls, v: str) -> str: """Validate that username is alphanumeric (with underscores allowed).""" if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", v): raise ValueError( "Username must start with a letter and contain only letters, numbers, and underscores" ) return v # --- Helper functions --- async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None: """Retrieve a tenant by ID.""" result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) return result.scalar_one_or_none() async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | None: """Retrieve an agent by ID.""" result = await db.execute(select(Agent).where(Agent.id == agent_id)) return result.scalar_one_or_none() async def get_online_agent_for_tenant( db: AsyncSessionDep, tenant_id: uuid.UUID ) -> Agent | None: """Get the first online agent for a tenant.""" result = await db.execute( select(Agent) .where(Agent.tenant_id == tenant_id) .where(Agent.status == "online") .limit(1) ) return result.scalar_one_or_none() async def check_nextcloud_availability(base_url: str) -> bool: """ Check if Nextcloud is available at the given URL. Performs a health check against {base_url}/status.php which returns JSON with installation status on a healthy Nextcloud instance. Args: base_url: The base URL for Nextcloud (e.g., "https://cloud.example.com") Returns: True if Nextcloud responds with a valid status, False otherwise """ status_url = f"{base_url.rstrip('/')}/status.php" try: async with httpx.AsyncClient( timeout=NEXTCLOUD_HEALTH_CHECK_TIMEOUT, follow_redirects=True, ) as client: response = await client.get(status_url) # Nextcloud status.php returns 200 with JSON when healthy if response.status_code == 200: # Try to parse as JSON - valid Nextcloud returns {"installed":true,...} try: data = response.json() # If we get valid JSON with "installed" key, Nextcloud is available return "installed" in data except (ValueError, KeyError): # Not valid JSON, but 200 OK might still indicate availability return True return False except (httpx.RequestError, httpx.TimeoutException): # Connection failed, timeout, or other network error return False # --- Route handlers --- @router.post( "/chatwoot/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def setup_chatwoot( tenant_id: uuid.UUID, request: ChatwootSetupRequest, db: AsyncSessionDep, ) -> Task: """ Trigger Chatwoot setup playbook for a tenant. Creates a COMPOSITE task with the following steps: 1. **ENV_UPDATE**: Set FRONTEND_URL and BACKEND_URL in chatwoot.env 2. **DOCKER_RELOAD**: Restart the Chatwoot stack with pull=True ## Request Body - **agent_id**: Optional agent UUID to assign the task to immediately - **domain**: The domain for Chatwoot (e.g., "support.example.com") ## Response Returns the created Task with type="COMPOSITE" and status="pending". The SysAdmin Agent will pick up this task and execute the steps in sequence. """ # Validate tenant exists tenant = await get_tenant_by_id(db, tenant_id) if tenant is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tenant {tenant_id} not found", ) # Validate agent exists if provided if request.agent_id is not None: agent = await get_agent_by_id(db, request.agent_id) if agent is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Agent {request.agent_id} not found", ) # Create the COMPOSITE task task = await create_chatwoot_setup_task( db=db, tenant_id=tenant_id, agent_id=request.agent_id, domain=request.domain, ) return task @router.post( "/nextcloud/set-domain", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def nextcloud_set_domain( tenant_id: uuid.UUID, request: NextcloudSetDomainRequest, db: AsyncSessionDep, ) -> Task: """ Set Nextcloud domain for a tenant (v1 - reload only). Creates a COMPOSITE task with a single DOCKER_RELOAD step. Auto-resolves the agent from the tenant (first online agent). **Note:** This is v1 of the playbook. It only reloads the Nextcloud stack. Future versions will add occ commands to configure config.php. ## Request Body - **public_url**: The public URL for Nextcloud (e.g., "https://cloud.example.com") - **pull**: Whether to pull images before reloading (default: false) ## Response Returns the created Task with type="COMPOSITE" and status="pending". The SysAdmin Agent will pick up this task and execute the steps in sequence. """ # Validate tenant exists tenant = await get_tenant_by_id(db, tenant_id) if tenant is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tenant {tenant_id} not found", ) # Auto-resolve agent (first online agent for tenant) agent = await get_online_agent_for_tenant(db, tenant_id) if agent is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No online agent found for tenant {tenant_id}", ) # Create the COMPOSITE task task = await create_nextcloud_set_domain_task( db=db, tenant_id=tenant_id, agent_id=agent.id, public_url=request.public_url, pull=request.pull, ) return task @router.post( "/nextcloud/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def nextcloud_initial_setup( tenant_id: uuid.UUID, request: NextcloudInitialSetupRequest, db: AsyncSessionDep, ) -> Task: """ Perform initial Nextcloud setup via Playwright browser automation. Creates a PLAYWRIGHT task that navigates to the Nextcloud installation page and creates the admin account. This should only be called once on a fresh Nextcloud installation. **Prerequisites:** - Tenant must have a domain configured - Nextcloud must be deployed and accessible at `https://cloud.{domain}` - An online agent must be registered for the tenant **Health Check:** Before creating the task, the endpoint checks if Nextcloud is available at `https://cloud.{domain}/status.php`. If not reachable, returns 409. ## Request Body - **admin_username**: Username for the admin account (3-64 chars, alphanumeric) - **admin_password**: Password for the admin account (minimum 8 characters) ## Response Returns the created Task with type="PLAYWRIGHT" and status="pending". ## Errors - **404**: Tenant not found - **400**: Tenant has no domain configured - **404**: No online agent found for tenant - **409**: Nextcloud not available (health check failed) - **422**: Invalid username or password format """ # Validate tenant exists tenant = await get_tenant_by_id(db, tenant_id) if tenant is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tenant {tenant_id} not found", ) # Validate tenant has domain configured if not tenant.domain: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Tenant {tenant_id} has no domain configured", ) # Auto-resolve agent (first online agent for tenant) agent = await get_online_agent_for_tenant(db, tenant_id) if agent is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No online agent found for tenant {tenant_id}", ) # Construct Nextcloud base URL from tenant domain base_url = f"https://cloud.{tenant.domain}" # Health check: verify Nextcloud is accessible is_available = await check_nextcloud_availability(base_url) if not is_available: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Nextcloud not available at {base_url}. " "Ensure Nextcloud is deployed and accessible before running setup.", ) # Create the PLAYWRIGHT task task = await create_nextcloud_initial_setup_task( db=db, tenant_id=tenant_id, agent_id=agent.id, base_url=base_url, admin_username=request.admin_username, admin_password=request.admin_password, ) return task