feat: add Nextcloud set-domain playbook v2

Add endpoint POST /tenants/{tenant_id}/nextcloud/set-domain that creates
a COMPOSITE task with two steps:
1. NEXTCLOUD_SET_DOMAIN - configures Nextcloud domain via occ commands
2. DOCKER_RELOAD - restarts the Nextcloud stack

Features:
- Auto-resolves first online agent for tenant
- Configurable pull flag for image updates
- Full test coverage (unit + integration tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-12-04 14:04:21 +01:00
parent f40c5fcc69
commit 06f58ca18b
6 changed files with 573 additions and 0 deletions

View File

@ -9,9 +9,15 @@ from app.playbooks.chatwoot import (
build_chatwoot_setup_steps, build_chatwoot_setup_steps,
create_chatwoot_setup_task, create_chatwoot_setup_task,
) )
from app.playbooks.nextcloud import (
build_nextcloud_set_domain_steps,
create_nextcloud_set_domain_task,
)
__all__ = [ __all__ = [
"CompositeStep", "CompositeStep",
"build_chatwoot_setup_steps", "build_chatwoot_setup_steps",
"create_chatwoot_setup_task", "create_chatwoot_setup_task",
"build_nextcloud_set_domain_steps",
"create_nextcloud_set_domain_task",
] ]

View File

@ -0,0 +1,99 @@
"""Nextcloud deployment playbook.
Defines the steps required to set Nextcloud domain on a tenant server
that already has stacks and env templates under /opt/letsbe.
v2: Configures Nextcloud via NEXTCLOUD_SET_DOMAIN task, then reloads the stack.
"""
import uuid
from typing import Any
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.task import Task, TaskStatus
class CompositeStep(BaseModel):
"""A single step in a composite playbook."""
type: str = Field(..., description="Task type (e.g., ENV_UPDATE, DOCKER_RELOAD)")
payload: dict[str, Any] = Field(
default_factory=dict, description="Payload for this step"
)
# LetsBe standard paths
NEXTCLOUD_STACK_DIR = "/opt/letsbe/stacks/nextcloud"
def build_nextcloud_set_domain_steps(*, public_url: str, pull: bool) -> list[CompositeStep]:
"""
Build the sequence of steps required to set Nextcloud domain (v2).
Args:
public_url: The public URL for Nextcloud (e.g., "https://cloud.example.com")
pull: Whether to pull images before reloading the stack
Returns:
List of 2 CompositeStep objects:
1. NEXTCLOUD_SET_DOMAIN - configures Nextcloud via occ commands
2. DOCKER_RELOAD - restarts the Nextcloud stack
"""
steps = [
# Step 1: Configure Nextcloud domain via occ
CompositeStep(
type="NEXTCLOUD_SET_DOMAIN",
payload={
"public_url": public_url,
},
),
# Step 2: Reload Docker stack
CompositeStep(
type="DOCKER_RELOAD",
payload={
"compose_dir": NEXTCLOUD_STACK_DIR,
"pull": pull,
},
),
]
return steps
async def create_nextcloud_set_domain_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
public_url: str,
pull: bool,
) -> Task:
"""
Create and persist a COMPOSITE task for Nextcloud set-domain.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: UUID of the agent to assign the task to
public_url: The public URL for Nextcloud
pull: Whether to pull images before reloading
Returns:
The created Task object with type="COMPOSITE"
"""
steps = build_nextcloud_set_domain_steps(public_url=public_url, pull=pull)
task = Task(
tenant_id=tenant_id,
agent_id=agent_id,
type="COMPOSITE",
payload={"steps": [step.model_dump() for step in steps]},
status=TaskStatus.PENDING.value,
)
db.add(task)
await db.commit()
await db.refresh(task)
return task

View File

@ -11,6 +11,7 @@ from app.models.agent import Agent
from app.models.task import Task from app.models.task import Task
from app.models.tenant import Tenant from app.models.tenant import Tenant
from app.playbooks.chatwoot import create_chatwoot_setup_task from app.playbooks.chatwoot import create_chatwoot_setup_task
from app.playbooks.nextcloud import create_nextcloud_set_domain_task
from app.schemas.task import TaskResponse from app.schemas.task import TaskResponse
router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"]) router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"])
@ -27,6 +28,20 @@ class ChatwootSetupRequest(BaseModel):
) )
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",
)
# --- Helper functions --- # --- Helper functions ---
@ -42,6 +57,19 @@ async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | N
return result.scalar_one_or_none() 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()
# --- Route handlers --- # --- Route handlers ---
@ -97,3 +125,59 @@ async def setup_chatwoot(
) )
return task 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

View File

@ -77,3 +77,20 @@ async def test_agent(db: AsyncSession) -> Agent:
await db.commit() await db.commit()
await db.refresh(agent) await db.refresh(agent)
return agent return agent
@pytest_asyncio.fixture(scope="function")
async def test_agent_for_tenant(db: AsyncSession, test_tenant: Tenant) -> Agent:
"""Create a test agent linked to test_tenant with online status."""
agent = Agent(
id=uuid.uuid4(),
tenant_id=test_tenant.id,
name="test-agent-for-tenant",
version="1.0.0",
status="online",
token="test-token-tenant",
)
db.add(agent)
await db.commit()
await db.refresh(agent)
return agent

View File

@ -0,0 +1,204 @@
"""Tests for Nextcloud playbook routes."""
import uuid
import pytest
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.agent import Agent
from app.models.task import Task
from app.models.tenant import Tenant
from app.playbooks.nextcloud import NEXTCLOUD_STACK_DIR
from app.routes.playbooks import (
NextcloudSetDomainRequest,
nextcloud_set_domain,
)
@pytest.mark.asyncio
class TestNextcloudSetDomainEndpoint:
"""Tests for the nextcloud_set_domain endpoint."""
async def test_happy_path_creates_task(
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
):
"""POST /tenants/{id}/nextcloud/set-domain returns 201 with COMPOSITE task."""
request = NextcloudSetDomainRequest(
public_url="https://cloud.example.com",
pull=False,
)
task = await nextcloud_set_domain(
tenant_id=test_tenant.id, request=request, db=db
)
assert task.id is not None
assert task.tenant_id == test_tenant.id
assert task.agent_id == test_agent_for_tenant.id
assert task.type == "COMPOSITE"
assert task.status == "pending"
async def test_task_has_both_steps(
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
):
"""Verify response task payload contains both steps in correct order."""
request = NextcloudSetDomainRequest(
public_url="https://cloud.example.com",
pull=True,
)
task = await nextcloud_set_domain(
tenant_id=test_tenant.id, request=request, db=db
)
assert "steps" in task.payload
assert len(task.payload["steps"]) == 2
# First step: NEXTCLOUD_SET_DOMAIN
step0 = task.payload["steps"][0]
assert step0["type"] == "NEXTCLOUD_SET_DOMAIN"
assert step0["payload"]["public_url"] == "https://cloud.example.com"
# Second step: DOCKER_RELOAD
step1 = task.payload["steps"][1]
assert step1["type"] == "DOCKER_RELOAD"
assert step1["payload"]["compose_dir"] == NEXTCLOUD_STACK_DIR
assert step1["payload"]["pull"] is True
async def test_pull_flag_default_false(
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
):
"""Verify pull defaults to False when not specified."""
request = NextcloudSetDomainRequest(
public_url="https://cloud.example.com",
)
task = await nextcloud_set_domain(
tenant_id=test_tenant.id, request=request, db=db
)
step = task.payload["steps"][1]
assert step["payload"]["pull"] is False
async def test_tenant_not_found_returns_404(self, db: AsyncSession):
"""Non-existent tenant_id returns 404."""
fake_tenant_id = uuid.uuid4()
request = NextcloudSetDomainRequest(
public_url="https://cloud.example.com",
pull=False,
)
with pytest.raises(HTTPException) as exc_info:
await nextcloud_set_domain(
tenant_id=fake_tenant_id, request=request, db=db
)
assert exc_info.value.status_code == 404
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
async def test_no_online_agent_returns_404(
self, db: AsyncSession, test_tenant: Tenant
):
"""Tenant with no online agent returns 404."""
request = NextcloudSetDomainRequest(
public_url="https://cloud.example.com",
pull=False,
)
with pytest.raises(HTTPException) as exc_info:
await nextcloud_set_domain(
tenant_id=test_tenant.id, request=request, db=db
)
assert exc_info.value.status_code == 404
assert "No online agent found" in str(exc_info.value.detail)
async def test_offline_agent_not_resolved(
self, db: AsyncSession, test_tenant: Tenant
):
"""Offline agent should not be auto-resolved."""
# Create an offline agent for the tenant
offline_agent = Agent(
id=uuid.uuid4(),
tenant_id=test_tenant.id,
name="offline-agent",
version="1.0.0",
status="offline",
token="offline-token",
)
db.add(offline_agent)
await db.commit()
request = NextcloudSetDomainRequest(
public_url="https://cloud.example.com",
pull=False,
)
with pytest.raises(HTTPException) as exc_info:
await nextcloud_set_domain(
tenant_id=test_tenant.id, request=request, db=db
)
assert exc_info.value.status_code == 404
assert "No online agent found" in str(exc_info.value.detail)
async def test_task_persisted_to_database(
self, db: AsyncSession, test_tenant: Tenant, test_agent_for_tenant: Agent
):
"""Verify the task is actually persisted and can be retrieved."""
request = NextcloudSetDomainRequest(
public_url="https://cloud.example.com",
pull=False,
)
task = await nextcloud_set_domain(
tenant_id=test_tenant.id, request=request, db=db
)
# Query the task back from the database
result = await db.execute(select(Task).where(Task.id == task.id))
retrieved_task = result.scalar_one_or_none()
assert retrieved_task is not None
assert retrieved_task.type == "COMPOSITE"
assert retrieved_task.tenant_id == test_tenant.id
assert retrieved_task.agent_id == test_agent_for_tenant.id
async def test_auto_resolves_first_online_agent(
self, db: AsyncSession, test_tenant: Tenant
):
"""Verify that the first online agent is auto-resolved."""
# Create two agents for the tenant - one offline, one online
offline_agent = Agent(
id=uuid.uuid4(),
tenant_id=test_tenant.id,
name="offline-agent",
version="1.0.0",
status="offline",
token="offline-token",
)
online_agent = Agent(
id=uuid.uuid4(),
tenant_id=test_tenant.id,
name="online-agent",
version="1.0.0",
status="online",
token="online-token",
)
db.add(offline_agent)
db.add(online_agent)
await db.commit()
request = NextcloudSetDomainRequest(
public_url="https://cloud.example.com",
pull=False,
)
task = await nextcloud_set_domain(
tenant_id=test_tenant.id, request=request, db=db
)
# Should have resolved to the online agent
assert task.agent_id == online_agent.id

View File

@ -0,0 +1,163 @@
"""Tests for the Nextcloud playbook module."""
import uuid
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.task import Task, TaskStatus
from app.models.tenant import Tenant
from app.playbooks.nextcloud import (
NEXTCLOUD_STACK_DIR,
CompositeStep,
build_nextcloud_set_domain_steps,
create_nextcloud_set_domain_task,
)
class TestBuildNextcloudSetDomainSteps:
"""Tests for the build_nextcloud_set_domain_steps function."""
def test_returns_two_steps(self):
"""Verify that build_nextcloud_set_domain_steps returns exactly 2 steps."""
steps = build_nextcloud_set_domain_steps(
public_url="https://cloud.example.com", pull=False
)
assert len(steps) == 2
assert all(isinstance(step, CompositeStep) for step in steps)
def test_first_step_is_nextcloud_set_domain(self):
"""Verify the first step is NEXTCLOUD_SET_DOMAIN."""
steps = build_nextcloud_set_domain_steps(
public_url="https://cloud.example.com", pull=False
)
assert steps[0].type == "NEXTCLOUD_SET_DOMAIN"
def test_nextcloud_set_domain_payload(self):
"""Verify NEXTCLOUD_SET_DOMAIN step has correct payload."""
public_url = "https://cloud.example.com"
steps = build_nextcloud_set_domain_steps(public_url=public_url, pull=False)
assert steps[0].payload["public_url"] == public_url
def test_docker_reload_payload(self):
"""Verify the DOCKER_RELOAD step has the correct payload structure."""
steps = build_nextcloud_set_domain_steps(
public_url="https://cloud.example.com", pull=False
)
docker_step = steps[1]
assert docker_step.type == "DOCKER_RELOAD"
assert docker_step.payload["compose_dir"] == NEXTCLOUD_STACK_DIR
assert docker_step.payload["pull"] is False
def test_pull_flag_true(self):
"""Verify that pull=True is passed correctly."""
steps = build_nextcloud_set_domain_steps(
public_url="https://cloud.example.com", pull=True
)
docker_step = steps[1]
assert docker_step.payload["pull"] is True
def test_pull_flag_false(self):
"""Verify that pull=False is passed correctly."""
steps = build_nextcloud_set_domain_steps(
public_url="https://cloud.example.com", pull=False
)
docker_step = steps[1]
assert docker_step.payload["pull"] is False
@pytest.mark.asyncio
class TestCreateNextcloudSetDomainTask:
"""Tests for the create_nextcloud_set_domain_task function."""
async def test_persists_composite_task(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that create_nextcloud_set_domain_task persists a COMPOSITE task."""
agent_id = uuid.uuid4()
task = await create_nextcloud_set_domain_task(
db=db,
tenant_id=test_tenant.id,
agent_id=agent_id,
public_url="https://cloud.example.com",
pull=False,
)
assert task.id is not None
assert task.tenant_id == test_tenant.id
assert task.agent_id == agent_id
assert task.type == "COMPOSITE"
assert task.status == TaskStatus.PENDING.value
async def test_task_payload_contains_steps(
self, db: AsyncSession, test_tenant: Tenant
):
"""Verify that the task payload contains the steps array."""
task = await create_nextcloud_set_domain_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
public_url="https://cloud.example.com",
pull=False,
)
assert "steps" in task.payload
assert len(task.payload["steps"]) == 2
async def test_task_steps_structure(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that the steps in the payload have the correct structure."""
task = await create_nextcloud_set_domain_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
public_url="https://cloud.example.com",
pull=True,
)
steps = task.payload["steps"]
# First step should be NEXTCLOUD_SET_DOMAIN
assert steps[0]["type"] == "NEXTCLOUD_SET_DOMAIN"
assert steps[0]["payload"]["public_url"] == "https://cloud.example.com"
# Second step should be DOCKER_RELOAD
assert steps[1]["type"] == "DOCKER_RELOAD"
assert steps[1]["payload"]["compose_dir"] == NEXTCLOUD_STACK_DIR
assert steps[1]["payload"]["pull"] is True
async def test_task_with_pull_false(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that pull=False is correctly stored in the task payload."""
task = await create_nextcloud_set_domain_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
public_url="https://cloud.example.com",
pull=False,
)
steps = task.payload["steps"]
assert steps[1]["payload"]["pull"] is False
async def test_task_persisted_to_database(
self, db: AsyncSession, test_tenant: Tenant
):
"""Verify the task is actually persisted and can be retrieved."""
task = await create_nextcloud_set_domain_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
public_url="https://cloud.example.com",
pull=False,
)
# Query the task back from the database
result = await db.execute(select(Task).where(Task.id == task.id))
retrieved_task = result.scalar_one_or_none()
assert retrieved_task is not None
assert retrieved_task.type == "COMPOSITE"
assert retrieved_task.tenant_id == test_tenant.id