letsbe-orchestrator/app/routes/playbooks.py

346 lines
11 KiB
Python

"""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