651 lines
20 KiB
Python
651 lines
20 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_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.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
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
# --- 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
|