"""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_initial_setup_task, create_chatwoot_setup_task, ) from app.playbooks.nextcloud import ( create_nextcloud_initial_setup_task, create_nextcloud_set_domain_task, ) from app.playbooks.poste import create_poste_initial_setup_task from app.playbooks.keycloak import ( create_keycloak_initial_setup_task, create_keycloak_setup_task, ) from app.playbooks.n8n import ( create_n8n_initial_setup_task, create_n8n_setup_task, ) from app.playbooks.calcom import ( create_calcom_initial_setup_task, create_calcom_setup_task, ) from app.playbooks.umami import ( create_umami_initial_setup_task, create_umami_setup_task, ) from app.playbooks.uptime_kuma import ( create_uptime_kuma_initial_setup_task, create_uptime_kuma_setup_task, ) from app.playbooks.vaultwarden import create_vaultwarden_setup_task from app.playbooks.portainer import create_portainer_setup_task from app.schemas.task import TaskResponse router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"]) # Health check timeout for Nextcloud availability 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 or a valid email address.""" username_pattern = r"^[a-zA-Z][a-zA-Z0-9_]*$" email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" if not (re.match(username_pattern, v) or re.match(email_pattern, v)): raise ValueError( "Username must be alphanumeric (starting with a letter) or a valid email address" ) return v class PosteInitialSetupRequest(BaseModel): """Request body for Poste.io initial setup via Playwright.""" admin_email: str = Field( ..., min_length=5, description="Email address for the Poste.io admin account (e.g., admin@example.com)", ) admin_password: str | None = Field( None, min_length=8, description="Password for admin account (auto-generated if not provided)", ) @field_validator("admin_email") @classmethod def validate_email(cls, v: str) -> str: """Validate that admin_email is a valid email address.""" email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" if not re.match(email_pattern, v): raise ValueError("admin_email must be a valid email address") return v class ChatwootInitialSetupRequest(BaseModel): """Request body for Chatwoot initial setup via Playwright.""" admin_name: str = Field( ..., min_length=2, max_length=100, description="Full name for the Chatwoot admin account", ) company_name: str = Field( ..., min_length=2, max_length=100, description="Company/organization name", ) admin_email: str = Field( ..., min_length=5, description="Email address for the Chatwoot admin account", ) admin_password: str | None = Field( None, min_length=8, description="Password for admin account (auto-generated if not provided)", ) @field_validator("admin_email") @classmethod def validate_email(cls, v: str) -> str: """Validate that admin_email is a valid email address.""" email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" if not re.match(email_pattern, v): raise ValueError("admin_email must be a valid email address") return v class KeycloakSetupRequest(BaseModel): """Request body for Keycloak 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 Keycloak (e.g., auth.example.com)" ) admin_user: str = Field( "admin", min_length=1, description="Admin username (default: admin)" ) admin_password: str = Field( ..., min_length=8, description="Admin password (minimum 8 characters)" ) class KeycloakInitialSetupRequest(BaseModel): """Request body for Keycloak initial setup via Playwright.""" admin_user: str = Field( "admin", min_length=1, description="Admin username" ) admin_password: str = Field( ..., min_length=8, description="Admin password" ) realm_name: str = Field( "letsbe", min_length=1, max_length=64, description="Realm name to create" ) class N8nSetupRequest(BaseModel): """Request body for n8n 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 n8n (e.g., n8n.example.com)" ) class N8nInitialSetupRequest(BaseModel): """Request body for n8n initial setup via Playwright.""" admin_email: str = Field( ..., min_length=5, description="Email address for the n8n owner account" ) admin_password: str | None = Field( None, min_length=8, description="Password for owner (auto-generated if not provided)" ) admin_first_name: str = Field( "Admin", min_length=1, description="First name for the owner account" ) admin_last_name: str = Field( "User", min_length=1, description="Last name for the owner account" ) @field_validator("admin_email") @classmethod def validate_email(cls, v: str) -> str: email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" if not re.match(email_pattern, v): raise ValueError("admin_email must be a valid email address") return v class CalcomSetupRequest(BaseModel): """Request body for Cal.com 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 Cal.com (e.g., cal.example.com)" ) class CalcomInitialSetupRequest(BaseModel): """Request body for Cal.com initial setup via Playwright.""" admin_email: str = Field( ..., min_length=5, description="Email address for the admin account" ) admin_password: str | None = Field( None, min_length=8, description="Password for admin (auto-generated if not provided)" ) admin_username: str = Field( "admin", min_length=1, max_length=64, description="Username for the admin account" ) admin_name: str = Field( "Admin", min_length=1, max_length=100, description="Display name for the admin account" ) @field_validator("admin_email") @classmethod def validate_email(cls, v: str) -> str: email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" if not re.match(email_pattern, v): raise ValueError("admin_email must be a valid email address") return v class UmamiSetupRequest(BaseModel): """Request body for Umami 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 Umami (e.g., analytics.example.com)" ) class UmamiInitialSetupRequest(BaseModel): """Request body for Umami initial setup via Playwright.""" admin_password: str | None = Field( None, min_length=8, description="New admin password (auto-generated if not provided)" ) website_name: str | None = Field( None, description="Optional name of the first website to add" ) website_url: str | None = Field( None, description="Optional URL of the first website to track" ) class UptimeKumaSetupRequest(BaseModel): """Request body for Uptime Kuma 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 Uptime Kuma (e.g., status.example.com)" ) class UptimeKumaInitialSetupRequest(BaseModel): """Request body for Uptime Kuma initial setup via Playwright.""" admin_username: str = Field( "admin", min_length=1, max_length=64, description="Username for the admin account" ) admin_password: str | None = Field( None, min_length=8, description="Password for admin (auto-generated if not provided)" ) class VaultwardenSetupRequest(BaseModel): """Request body for Vaultwarden 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 Vaultwarden (e.g., vault.example.com)" ) admin_token: str = Field( ..., min_length=8, description="Admin panel access token" ) signups_allowed: bool = Field( True, description="Whether new user registration is allowed" ) class PortainerSetupRequest(BaseModel): """Request body for Portainer 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 Portainer (e.g., portainer.example.com)" ) # --- 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=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 async def check_poste_availability(base_url: str) -> bool: """ Check if Poste.io is available at the given URL. Performs a health check against the base URL to verify the service is up. Args: base_url: The base URL for Poste.io (e.g., "https://mail.example.com") Returns: True if Poste.io responds, False otherwise """ try: async with httpx.AsyncClient( timeout=HEALTH_CHECK_TIMEOUT, follow_redirects=True, ) as client: response = await client.get(base_url.rstrip("/")) # Poste.io returns 200 on setup page or login page return response.status_code == 200 except (httpx.RequestError, httpx.TimeoutException): return False async def check_chatwoot_availability(base_url: str) -> bool: """ Check if Chatwoot is available at the given URL. Performs a health check against the base URL to verify the service is up. Args: base_url: The base URL for Chatwoot (e.g., "https://chatwoot.example.com") Returns: True if Chatwoot responds, False otherwise """ try: async with httpx.AsyncClient( timeout=HEALTH_CHECK_TIMEOUT, follow_redirects=True, ) as client: response = await client.get(base_url.rstrip("/")) # Chatwoot returns 200 on setup page or login page return response.status_code == 200 except (httpx.RequestError, httpx.TimeoutException): return False async def check_service_availability(base_url: str) -> bool: """ Generic health check for a web service. Performs a simple GET request to verify the service is up and responding. Args: base_url: The base URL for the service Returns: True if the service responds with HTTP 200, False otherwise """ try: async with httpx.AsyncClient( timeout=HEALTH_CHECK_TIMEOUT, follow_redirects=True, ) as client: response = await client.get(base_url.rstrip("/")) return response.status_code == 200 except (httpx.RequestError, httpx.TimeoutException): 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 @router.post( "/poste/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def poste_initial_setup( tenant_id: uuid.UUID, request: PosteInitialSetupRequest, db: AsyncSessionDep, ) -> Task: """ Perform initial Poste.io setup via Playwright browser automation. Creates a PLAYWRIGHT task that navigates to the Poste.io setup wizard, configures the mailserver hostname, and creates the admin account. This should only be called once on a fresh Poste.io installation. **Prerequisites:** - Tenant must have a domain configured - Poste.io must be deployed and accessible at `https://mail.{domain}` - An online agent must be registered for the tenant **Health Check:** Before creating the task, the endpoint checks if Poste.io is available. If not reachable, returns 409. ## Request Body - **admin_email**: Email address for the admin account (e.g., admin@example.com) - **admin_password**: Password for admin (auto-generated if not provided) ## Response Returns the created Task with type="PLAYWRIGHT" and status="pending". The task result will contain the generated/provided credentials. ## Errors - **404**: Tenant not found - **400**: Tenant has no domain configured - **404**: No online agent found for tenant - **409**: Poste.io not available (health check failed) - **422**: Invalid email 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 Poste.io base URL from tenant domain base_url = f"https://mail.{tenant.domain}" # Health check: verify Poste.io is accessible is_available = await check_poste_availability(base_url) if not is_available: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Poste.io not available at {base_url}. " "Ensure Poste.io is deployed and accessible before running setup.", ) # Create the PLAYWRIGHT task task = await create_poste_initial_setup_task( db=db, tenant_id=tenant_id, agent_id=agent.id, base_url=base_url, admin_email=request.admin_email, admin_password=request.admin_password, ) return task @router.post( "/chatwoot/initial-setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def chatwoot_initial_setup( tenant_id: uuid.UUID, request: ChatwootInitialSetupRequest, db: AsyncSessionDep, ) -> Task: """ Perform initial Chatwoot setup via Playwright browser automation. Creates a PLAYWRIGHT task that navigates to the Chatwoot setup wizard, creates the super admin account, unchecks newsletter subscription, and completes the setup. This should only be called once on a fresh Chatwoot installation. **Prerequisites:** - Tenant must have a domain configured - Chatwoot must be deployed and accessible at `https://chatwoot.{domain}` - An online agent must be registered for the tenant **Health Check:** Before creating the task, the endpoint checks if Chatwoot is available. If not reachable, returns 409. ## Request Body - **admin_name**: Full name for the admin account - **company_name**: Company/organization name - **admin_email**: Email address for the admin account - **admin_password**: Password for admin (auto-generated if not provided) ## Response Returns the created Task with type="PLAYWRIGHT" and status="pending". The task result will contain the generated/provided credentials. ## Errors - **404**: Tenant not found - **400**: Tenant has no domain configured - **404**: No online agent found for tenant - **409**: Chatwoot not available (health check failed) - **422**: Invalid email 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 Chatwoot base URL from tenant domain base_url = f"https://chatwoot.{tenant.domain}" # Health check: verify Chatwoot is accessible is_available = await check_chatwoot_availability(base_url) if not is_available: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Chatwoot not available at {base_url}. " "Ensure Chatwoot is deployed and accessible before running setup.", ) # Create the PLAYWRIGHT task task = await create_chatwoot_initial_setup_task( db=db, tenant_id=tenant_id, agent_id=agent.id, base_url=base_url, admin_name=request.admin_name, company_name=request.company_name, admin_email=request.admin_email, admin_password=request.admin_password, ) return task # ============================================================================= # Keycloak # ============================================================================= @router.post( "/keycloak/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def setup_keycloak( tenant_id: uuid.UUID, request: KeycloakSetupRequest, db: AsyncSessionDep, ) -> Task: """ Trigger Keycloak setup playbook for a tenant. Creates a COMPOSITE task with the following steps: 1. **ENV_UPDATE**: Set KC_HOSTNAME, KEYCLOAK_ADMIN, KEYCLOAK_ADMIN_PASSWORD 2. **DOCKER_RELOAD**: Restart the Keycloak stack with pull=True """ 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", ) 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", ) task = await create_keycloak_setup_task( db=db, tenant_id=tenant_id, agent_id=request.agent_id, domain=request.domain, admin_user=request.admin_user, admin_password=request.admin_password, ) return task @router.post( "/keycloak/initial-setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def keycloak_initial_setup( tenant_id: uuid.UUID, request: KeycloakInitialSetupRequest, db: AsyncSessionDep, ) -> Task: """ Perform initial Keycloak setup via Playwright browser automation. Creates a PLAYWRIGHT task that logs into the Keycloak admin console and creates the "letsbe" realm. This should only be called once after Keycloak is deployed. **Prerequisites:** - Tenant must have a domain configured - Keycloak must be deployed and accessible at `https://auth.{domain}` - An online agent must be registered for the tenant """ 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", ) if not tenant.domain: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Tenant {tenant_id} has no domain configured", ) 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}", ) base_url = f"https://auth.{tenant.domain}" is_available = await check_service_availability(base_url) if not is_available: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Keycloak not available at {base_url}. " "Ensure Keycloak is deployed and accessible before running setup.", ) task = await create_keycloak_initial_setup_task( db=db, tenant_id=tenant_id, agent_id=agent.id, base_url=base_url, admin_user=request.admin_user, admin_password=request.admin_password, realm_name=request.realm_name, ) return task # ============================================================================= # n8n # ============================================================================= @router.post( "/n8n/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def setup_n8n( tenant_id: uuid.UUID, request: N8nSetupRequest, db: AsyncSessionDep, ) -> Task: """ Trigger n8n setup playbook for a tenant. Creates a COMPOSITE task with the following steps: 1. **ENV_UPDATE**: Set N8N_HOST, N8N_PROTOCOL, WEBHOOK_URL 2. **DOCKER_RELOAD**: Restart the n8n stack with pull=True """ 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", ) 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", ) task = await create_n8n_setup_task( db=db, tenant_id=tenant_id, agent_id=request.agent_id, domain=request.domain, ) return task @router.post( "/n8n/initial-setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def n8n_initial_setup( tenant_id: uuid.UUID, request: N8nInitialSetupRequest, db: AsyncSessionDep, ) -> Task: """ Perform initial n8n setup via Playwright browser automation. Creates a PLAYWRIGHT task that creates the owner account on a fresh n8n installation. **Prerequisites:** - Tenant must have a domain configured - n8n must be deployed and accessible at `https://n8n.{domain}` - An online agent must be registered for the tenant """ 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", ) if not tenant.domain: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Tenant {tenant_id} has no domain configured", ) 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}", ) base_url = f"https://n8n.{tenant.domain}" is_available = await check_service_availability(base_url) if not is_available: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"n8n not available at {base_url}. " "Ensure n8n is deployed and accessible before running setup.", ) task = await create_n8n_initial_setup_task( db=db, tenant_id=tenant_id, agent_id=agent.id, base_url=base_url, admin_email=request.admin_email, admin_password=request.admin_password, admin_first_name=request.admin_first_name, admin_last_name=request.admin_last_name, ) return task # ============================================================================= # Cal.com # ============================================================================= @router.post( "/calcom/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def setup_calcom( tenant_id: uuid.UUID, request: CalcomSetupRequest, db: AsyncSessionDep, ) -> Task: """ Trigger Cal.com setup playbook for a tenant. Creates a COMPOSITE task with the following steps: 1. **ENV_UPDATE**: Set NEXT_PUBLIC_WEBAPP_URL, NEXTAUTH_URL 2. **DOCKER_RELOAD**: Restart the Cal.com stack with pull=True """ 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", ) 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", ) task = await create_calcom_setup_task( db=db, tenant_id=tenant_id, agent_id=request.agent_id, domain=request.domain, ) return task @router.post( "/calcom/initial-setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def calcom_initial_setup( tenant_id: uuid.UUID, request: CalcomInitialSetupRequest, db: AsyncSessionDep, ) -> Task: """ Perform initial Cal.com setup via Playwright browser automation. Creates a PLAYWRIGHT task that creates the admin account on a fresh Cal.com installation. **Prerequisites:** - Tenant must have a domain configured - Cal.com must be deployed and accessible at `https://cal.{domain}` - An online agent must be registered for the tenant """ 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", ) if not tenant.domain: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Tenant {tenant_id} has no domain configured", ) 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}", ) base_url = f"https://cal.{tenant.domain}" is_available = await check_service_availability(base_url) if not is_available: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Cal.com not available at {base_url}. " "Ensure Cal.com is deployed and accessible before running setup.", ) task = await create_calcom_initial_setup_task( db=db, tenant_id=tenant_id, agent_id=agent.id, base_url=base_url, admin_email=request.admin_email, admin_password=request.admin_password, admin_username=request.admin_username, admin_name=request.admin_name, ) return task # ============================================================================= # Umami # ============================================================================= @router.post( "/umami/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def setup_umami( tenant_id: uuid.UUID, request: UmamiSetupRequest, db: AsyncSessionDep, ) -> Task: """ Trigger Umami setup playbook for a tenant. Creates a COMPOSITE task with the following steps: 1. **ENV_UPDATE**: Set APP_URL 2. **DOCKER_RELOAD**: Restart the Umami stack with pull=True """ 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", ) 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", ) task = await create_umami_setup_task( db=db, tenant_id=tenant_id, agent_id=request.agent_id, domain=request.domain, ) return task @router.post( "/umami/initial-setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def umami_initial_setup( tenant_id: uuid.UUID, request: UmamiInitialSetupRequest, db: AsyncSessionDep, ) -> Task: """ Perform initial Umami setup via Playwright browser automation. Creates a PLAYWRIGHT task that logs in with default credentials (admin/umami), changes the admin password, and optionally adds the first website to track. **Prerequisites:** - Tenant must have a domain configured - Umami must be deployed and accessible at `https://analytics.{domain}` - An online agent must be registered for the tenant """ 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", ) if not tenant.domain: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Tenant {tenant_id} has no domain configured", ) 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}", ) base_url = f"https://analytics.{tenant.domain}" is_available = await check_service_availability(base_url) if not is_available: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Umami not available at {base_url}. " "Ensure Umami is deployed and accessible before running setup.", ) task = await create_umami_initial_setup_task( db=db, tenant_id=tenant_id, agent_id=agent.id, base_url=base_url, admin_password=request.admin_password, website_name=request.website_name, website_url=request.website_url, ) return task # ============================================================================= # Uptime Kuma # ============================================================================= @router.post( "/uptime-kuma/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def setup_uptime_kuma( tenant_id: uuid.UUID, request: UptimeKumaSetupRequest, db: AsyncSessionDep, ) -> Task: """ Trigger Uptime Kuma setup playbook for a tenant. Creates a COMPOSITE task with the following steps: 1. **ENV_UPDATE**: Set UPTIME_KUMA_DOMAIN 2. **DOCKER_RELOAD**: Restart the Uptime Kuma stack with pull=True """ 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", ) 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", ) task = await create_uptime_kuma_setup_task( db=db, tenant_id=tenant_id, agent_id=request.agent_id, domain=request.domain, ) return task @router.post( "/uptime-kuma/initial-setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def uptime_kuma_initial_setup( tenant_id: uuid.UUID, request: UptimeKumaInitialSetupRequest, db: AsyncSessionDep, ) -> Task: """ Perform initial Uptime Kuma setup via Playwright browser automation. Creates a PLAYWRIGHT task that creates the admin account on a fresh Uptime Kuma installation. **Prerequisites:** - Tenant must have a domain configured - Uptime Kuma must be deployed and accessible at `https://status.{domain}` - An online agent must be registered for the tenant """ 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", ) if not tenant.domain: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Tenant {tenant_id} has no domain configured", ) 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}", ) base_url = f"https://status.{tenant.domain}" is_available = await check_service_availability(base_url) if not is_available: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Uptime Kuma not available at {base_url}. " "Ensure Uptime Kuma is deployed and accessible before running setup.", ) task = await create_uptime_kuma_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 # ============================================================================= # Vaultwarden (no Playwright) # ============================================================================= @router.post( "/vaultwarden/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def setup_vaultwarden( tenant_id: uuid.UUID, request: VaultwardenSetupRequest, db: AsyncSessionDep, ) -> Task: """ Trigger Vaultwarden setup playbook for a tenant. Creates a COMPOSITE task with the following steps: 1. **ENV_UPDATE**: Set DOMAIN, ADMIN_TOKEN, SIGNUPS_ALLOWED 2. **DOCKER_RELOAD**: Restart the Vaultwarden stack with pull=True """ 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", ) 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", ) task = await create_vaultwarden_setup_task( db=db, tenant_id=tenant_id, agent_id=request.agent_id, domain=request.domain, admin_token=request.admin_token, signups_allowed=request.signups_allowed, ) return task # ============================================================================= # Portainer (no Playwright) # ============================================================================= @router.post( "/portainer/setup", response_model=TaskResponse, status_code=status.HTTP_201_CREATED, ) async def setup_portainer( tenant_id: uuid.UUID, request: PortainerSetupRequest, db: AsyncSessionDep, ) -> Task: """ Trigger Portainer setup playbook for a tenant. Creates a COMPOSITE task with the following steps: 1. **ENV_UPDATE**: Set PORTAINER_DOMAIN 2. **DOCKER_RELOAD**: Restart the Portainer stack with pull=True """ 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", ) 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", ) task = await create_portainer_setup_task( db=db, tenant_id=tenant_id, agent_id=request.agent_id, domain=request.domain, ) return task