letsbe-orchestrator/app/routes/playbooks.py

348 lines
11 KiB
Python
Raw Normal View History

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