diff --git a/app/playbooks/nextcloud.py b/app/playbooks/nextcloud.py index 9c0ca27..6a363a6 100644 --- a/app/playbooks/nextcloud.py +++ b/app/playbooks/nextcloud.py @@ -1,13 +1,15 @@ """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. +Defines the steps required to: +1. Set Nextcloud domain on a tenant server (v2: via NEXTCLOUD_SET_DOMAIN task) +2. Perform initial setup via Playwright automation (create admin account) -v2: Configures Nextcloud via NEXTCLOUD_SET_DOMAIN task, then reloads the stack. +Tenant servers must have stacks and env templates under /opt/letsbe. """ import uuid from typing import Any +from urllib.parse import urlparse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -28,6 +30,95 @@ class CompositeStep(BaseModel): NEXTCLOUD_STACK_DIR = "/opt/letsbe/stacks/nextcloud" +# ============================================================================= +# Initial Setup via Playwright +# ============================================================================= + + +def build_nextcloud_initial_setup_step( + *, + base_url: str, + admin_username: str, + admin_password: str, +) -> dict[str, Any]: + """ + Build a PLAYWRIGHT task payload for Nextcloud initial setup. + + This creates the admin account on a fresh Nextcloud installation. + + Args: + base_url: The base URL for Nextcloud (e.g., "https://cloud.example.com") + admin_username: Username for the admin account + admin_password: Password for the admin account + + Returns: + Task payload dict with type="PLAYWRIGHT" + """ + # Extract domain from URL for allowlist + parsed = urlparse(base_url) + allowed_domain = parsed.netloc # e.g., "cloud.example.com" + + return { + "scenario": "nextcloud.initial_setup", + "inputs": { + "base_url": base_url, + "admin_username": admin_username, + "admin_password": admin_password, + "allowed_domains": [allowed_domain], + }, + "timeout": 120, + } + + +async def create_nextcloud_initial_setup_task( + *, + db: AsyncSession, + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + base_url: str, + admin_username: str, + admin_password: str, +) -> Task: + """ + Create and persist a PLAYWRIGHT task for Nextcloud initial setup. + + Args: + db: Async database session + tenant_id: UUID of the tenant + agent_id: UUID of the agent to assign the task to + base_url: The base URL for Nextcloud + admin_username: Username for the admin account + admin_password: Password for the admin account + + Returns: + The created Task object with type="PLAYWRIGHT" + """ + payload = build_nextcloud_initial_setup_step( + base_url=base_url, + admin_username=admin_username, + admin_password=admin_password, + ) + + task = Task( + tenant_id=tenant_id, + agent_id=agent_id, + type="PLAYWRIGHT", + payload=payload, + status=TaskStatus.PENDING.value, + ) + + db.add(task) + await db.commit() + await db.refresh(task) + + return task + + +# ============================================================================= +# Set Domain via NEXTCLOUD_SET_DOMAIN +# ============================================================================= + + def build_nextcloud_set_domain_steps(*, public_url: str, pull: bool) -> list[CompositeStep]: """ Build the sequence of steps required to set Nextcloud domain (v2). diff --git a/app/routes/playbooks.py b/app/routes/playbooks.py index 31d73c3..8b9c95a 100644 --- a/app/routes/playbooks.py +++ b/app/routes/playbooks.py @@ -1,9 +1,11 @@ """Playbook endpoints for triggering infrastructure automation.""" +import re import uuid +import httpx from fastapi import APIRouter, HTTPException, status -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from app.db import AsyncSessionDep @@ -11,11 +13,17 @@ 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_set_domain_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.""" @@ -42,6 +50,32 @@ class NextcloudSetDomainRequest(BaseModel): ) +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 --- @@ -70,6 +104,46 @@ async def get_online_agent_for_tenant( 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 --- @@ -181,3 +255,91 @@ async def nextcloud_set_domain( ) 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 diff --git a/requirements.txt b/requirements.txt index 8f271a8..9b6fa0f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,9 @@ pydantic-settings>=2.1.0 # Utilities python-dotenv>=1.0.0 +# HTTP Client +httpx>=0.26.0 + # Testing pytest>=8.0.0 pytest-asyncio>=0.23.0 diff --git a/tests/routes/test_nextcloud_routes.py b/tests/routes/test_nextcloud_routes.py index 23834ed..6d39273 100644 --- a/tests/routes/test_nextcloud_routes.py +++ b/tests/routes/test_nextcloud_routes.py @@ -1,9 +1,12 @@ """Tests for Nextcloud playbook routes.""" import uuid +from unittest.mock import AsyncMock, patch import pytest +import pytest_asyncio from fastapi import HTTPException +from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -12,7 +15,9 @@ 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 ( + NextcloudInitialSetupRequest, NextcloudSetDomainRequest, + nextcloud_initial_setup, nextcloud_set_domain, ) @@ -202,3 +207,285 @@ class TestNextcloudSetDomainEndpoint: # Should have resolved to the online agent assert task.agent_id == online_agent.id + + +# ============================================================================= +# Fixtures for Initial Setup Tests +# ============================================================================= + + +@pytest_asyncio.fixture(scope="function") +async def test_tenant_with_domain(db: AsyncSession) -> Tenant: + """Create a test tenant with domain configured.""" + tenant = Tenant( + id=uuid.uuid4(), + name="Test Tenant With Domain", + domain="example.com", + ) + db.add(tenant) + await db.commit() + await db.refresh(tenant) + return tenant + + +@pytest_asyncio.fixture(scope="function") +async def test_agent_for_tenant_with_domain( + db: AsyncSession, test_tenant_with_domain: Tenant +) -> Agent: + """Create a test agent linked to test_tenant_with_domain with online status.""" + agent = Agent( + id=uuid.uuid4(), + tenant_id=test_tenant_with_domain.id, + name="test-agent-for-tenant-domain", + version="1.0.0", + status="online", + token="test-token-domain", + ) + db.add(agent) + await db.commit() + await db.refresh(agent) + return agent + + +# ============================================================================= +# Tests for Nextcloud Initial Setup Endpoint +# ============================================================================= + + +@pytest.mark.asyncio +class TestNextcloudInitialSetupEndpoint: + """Tests for the nextcloud_initial_setup endpoint.""" + + @patch("app.routes.playbooks.check_nextcloud_availability") + async def test_happy_path_creates_task( + self, + mock_health_check: AsyncMock, + db: AsyncSession, + test_tenant_with_domain: Tenant, + test_agent_for_tenant_with_domain: Agent, + ): + """POST /tenants/{id}/nextcloud/setup returns 201 with PLAYWRIGHT task.""" + mock_health_check.return_value = True + + request = NextcloudInitialSetupRequest( + admin_username="admin", + admin_password="securepassword123", + ) + + task = await nextcloud_initial_setup( + tenant_id=test_tenant_with_domain.id, request=request, db=db + ) + + assert task.id is not None + assert task.tenant_id == test_tenant_with_domain.id + assert task.agent_id == test_agent_for_tenant_with_domain.id + assert task.type == "PLAYWRIGHT" + assert task.status == "pending" + + @patch("app.routes.playbooks.check_nextcloud_availability") + async def test_task_has_correct_payload( + self, + mock_health_check: AsyncMock, + db: AsyncSession, + test_tenant_with_domain: Tenant, + test_agent_for_tenant_with_domain: Agent, + ): + """Verify response task payload contains scenario and inputs.""" + mock_health_check.return_value = True + + request = NextcloudInitialSetupRequest( + admin_username="myadmin", + admin_password="mypassword123", + ) + + task = await nextcloud_initial_setup( + tenant_id=test_tenant_with_domain.id, request=request, db=db + ) + + assert task.payload["scenario"] == "nextcloud.initial_setup" + inputs = task.payload["inputs"] + assert inputs["base_url"] == "https://cloud.example.com" + assert inputs["admin_username"] == "myadmin" + assert inputs["admin_password"] == "mypassword123" + assert inputs["allowed_domains"] == ["cloud.example.com"] + + async def test_tenant_not_found_returns_404(self, db: AsyncSession): + """Non-existent tenant_id returns 404.""" + fake_tenant_id = uuid.uuid4() + request = NextcloudInitialSetupRequest( + admin_username="admin", + admin_password="password123", + ) + + with pytest.raises(HTTPException) as exc_info: + await nextcloud_initial_setup( + 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_tenant_without_domain_returns_400( + self, db: AsyncSession, test_tenant: Tenant + ): + """Tenant without domain configured returns 400.""" + request = NextcloudInitialSetupRequest( + admin_username="admin", + admin_password="password123", + ) + + with pytest.raises(HTTPException) as exc_info: + await nextcloud_initial_setup( + tenant_id=test_tenant.id, request=request, db=db + ) + + assert exc_info.value.status_code == 400 + assert "no domain configured" in str(exc_info.value.detail) + + async def test_no_online_agent_returns_404( + self, db: AsyncSession, test_tenant_with_domain: Tenant + ): + """Tenant with no online agent returns 404.""" + request = NextcloudInitialSetupRequest( + admin_username="admin", + admin_password="password123", + ) + + with pytest.raises(HTTPException) as exc_info: + await nextcloud_initial_setup( + tenant_id=test_tenant_with_domain.id, request=request, db=db + ) + + assert exc_info.value.status_code == 404 + assert "No online agent found" in str(exc_info.value.detail) + + @patch("app.routes.playbooks.check_nextcloud_availability") + async def test_nextcloud_unavailable_returns_409( + self, + mock_health_check: AsyncMock, + db: AsyncSession, + test_tenant_with_domain: Tenant, + test_agent_for_tenant_with_domain: Agent, + ): + """Health check failure returns 409.""" + mock_health_check.return_value = False + + request = NextcloudInitialSetupRequest( + admin_username="admin", + admin_password="password123", + ) + + with pytest.raises(HTTPException) as exc_info: + await nextcloud_initial_setup( + tenant_id=test_tenant_with_domain.id, request=request, db=db + ) + + assert exc_info.value.status_code == 409 + assert "Nextcloud not available" in str(exc_info.value.detail) + + @patch("app.routes.playbooks.check_nextcloud_availability") + async def test_health_check_called_with_correct_url( + self, + mock_health_check: AsyncMock, + db: AsyncSession, + test_tenant_with_domain: Tenant, + test_agent_for_tenant_with_domain: Agent, + ): + """Verify health check is called with correct URL.""" + mock_health_check.return_value = True + + request = NextcloudInitialSetupRequest( + admin_username="admin", + admin_password="password123", + ) + + await nextcloud_initial_setup( + tenant_id=test_tenant_with_domain.id, request=request, db=db + ) + + mock_health_check.assert_called_once_with("https://cloud.example.com") + + def test_username_too_short_raises_validation_error(self): + """Username less than 3 characters raises ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + NextcloudInitialSetupRequest( + admin_username="ab", # Too short + admin_password="password123", + ) + + assert "admin_username" in str(exc_info.value) + + def test_username_invalid_chars_raises_validation_error(self): + """Username with invalid characters raises ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + NextcloudInitialSetupRequest( + admin_username="admin@user", # Invalid char @ + admin_password="password123", + ) + + assert "admin_username" in str(exc_info.value) + + def test_username_starting_with_number_raises_validation_error(self): + """Username starting with number raises ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + NextcloudInitialSetupRequest( + admin_username="123admin", # Starts with number + admin_password="password123", + ) + + assert "admin_username" in str(exc_info.value) + + def test_password_too_short_raises_validation_error(self): + """Password less than 8 characters raises ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + NextcloudInitialSetupRequest( + admin_username="admin", + admin_password="short", # Too short + ) + + assert "admin_password" in str(exc_info.value) + + def test_valid_username_with_underscore(self): + """Username with underscore is valid.""" + request = NextcloudInitialSetupRequest( + admin_username="admin_user", + admin_password="password123", + ) + assert request.admin_username == "admin_user" + + def test_valid_username_with_numbers(self): + """Username with numbers (not at start) is valid.""" + request = NextcloudInitialSetupRequest( + admin_username="admin123", + admin_password="password123", + ) + assert request.admin_username == "admin123" + + @patch("app.routes.playbooks.check_nextcloud_availability") + async def test_task_persisted_to_database( + self, + mock_health_check: AsyncMock, + db: AsyncSession, + test_tenant_with_domain: Tenant, + test_agent_for_tenant_with_domain: Agent, + ): + """Verify the task is actually persisted and can be retrieved.""" + mock_health_check.return_value = True + + request = NextcloudInitialSetupRequest( + admin_username="admin", + admin_password="password123", + ) + + task = await nextcloud_initial_setup( + tenant_id=test_tenant_with_domain.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 == "PLAYWRIGHT" + assert retrieved_task.tenant_id == test_tenant_with_domain.id + assert retrieved_task.agent_id == test_agent_for_tenant_with_domain.id diff --git a/tests/test_playbooks_nextcloud.py b/tests/test_playbooks_nextcloud.py index e78ba44..df69a99 100644 --- a/tests/test_playbooks_nextcloud.py +++ b/tests/test_playbooks_nextcloud.py @@ -12,11 +12,162 @@ from app.models.tenant import Tenant from app.playbooks.nextcloud import ( NEXTCLOUD_STACK_DIR, CompositeStep, + build_nextcloud_initial_setup_step, build_nextcloud_set_domain_steps, + create_nextcloud_initial_setup_task, create_nextcloud_set_domain_task, ) +# ============================================================================= +# Tests for Playwright Initial Setup +# ============================================================================= + + +class TestBuildNextcloudInitialSetupStep: + """Tests for the build_nextcloud_initial_setup_step function.""" + + def test_returns_playwright_payload(self): + """Verify that build_nextcloud_initial_setup_step returns correct structure.""" + payload = build_nextcloud_initial_setup_step( + base_url="https://cloud.example.com", + admin_username="admin", + admin_password="securepassword123", + ) + + assert payload["scenario"] == "nextcloud.initial_setup" + assert "inputs" in payload + assert "timeout" in payload + + def test_inputs_contain_required_fields(self): + """Verify that inputs contain all required fields.""" + payload = build_nextcloud_initial_setup_step( + base_url="https://cloud.example.com", + admin_username="admin", + admin_password="securepassword123", + ) + + inputs = payload["inputs"] + assert inputs["base_url"] == "https://cloud.example.com" + assert inputs["admin_username"] == "admin" + assert inputs["admin_password"] == "securepassword123" + assert "allowed_domains" in inputs + + def test_allowed_domains_extracted_from_url(self): + """Verify that allowed_domains is extracted from base_url.""" + payload = build_nextcloud_initial_setup_step( + base_url="https://cloud.example.com", + admin_username="admin", + admin_password="password", + ) + + assert payload["inputs"]["allowed_domains"] == ["cloud.example.com"] + + def test_allowed_domains_with_port(self): + """Verify that allowed_domains handles URLs with ports.""" + payload = build_nextcloud_initial_setup_step( + base_url="https://cloud.example.com:8443", + admin_username="admin", + admin_password="password", + ) + + assert payload["inputs"]["allowed_domains"] == ["cloud.example.com:8443"] + + def test_timeout_is_set(self): + """Verify that timeout is set in the payload.""" + payload = build_nextcloud_initial_setup_step( + base_url="https://cloud.example.com", + admin_username="admin", + admin_password="password", + ) + + assert payload["timeout"] == 120 + + +@pytest.mark.asyncio +class TestCreateNextcloudInitialSetupTask: + """Tests for the create_nextcloud_initial_setup_task function.""" + + async def test_persists_playwright_task(self, db: AsyncSession, test_tenant: Tenant): + """Verify that create_nextcloud_initial_setup_task persists a PLAYWRIGHT task.""" + agent_id = uuid.uuid4() + + task = await create_nextcloud_initial_setup_task( + db=db, + tenant_id=test_tenant.id, + agent_id=agent_id, + base_url="https://cloud.example.com", + admin_username="admin", + admin_password="securepassword123", + ) + + assert task.id is not None + assert task.tenant_id == test_tenant.id + assert task.agent_id == agent_id + assert task.type == "PLAYWRIGHT" + assert task.status == TaskStatus.PENDING.value + + async def test_task_payload_contains_scenario( + self, db: AsyncSession, test_tenant: Tenant + ): + """Verify that the task payload contains the scenario field.""" + task = await create_nextcloud_initial_setup_task( + db=db, + tenant_id=test_tenant.id, + agent_id=uuid.uuid4(), + base_url="https://cloud.example.com", + admin_username="admin", + admin_password="password", + ) + + assert task.payload["scenario"] == "nextcloud.initial_setup" + + async def test_task_payload_contains_inputs( + self, db: AsyncSession, test_tenant: Tenant + ): + """Verify that the task payload contains the inputs field.""" + task = await create_nextcloud_initial_setup_task( + db=db, + tenant_id=test_tenant.id, + agent_id=uuid.uuid4(), + base_url="https://cloud.example.com", + admin_username="testadmin", + admin_password="testpassword123", + ) + + inputs = task.payload["inputs"] + assert inputs["base_url"] == "https://cloud.example.com" + assert inputs["admin_username"] == "testadmin" + assert inputs["admin_password"] == "testpassword123" + assert inputs["allowed_domains"] == ["cloud.example.com"] + + 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_initial_setup_task( + db=db, + tenant_id=test_tenant.id, + agent_id=uuid.uuid4(), + base_url="https://cloud.example.com", + admin_username="admin", + admin_password="password", + ) + + # 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 == "PLAYWRIGHT" + assert retrieved_task.tenant_id == test_tenant.id + + +# ============================================================================= +# Tests for Set Domain Playbook +# ============================================================================= + + class TestBuildNextcloudSetDomainSteps: """Tests for the build_nextcloud_set_domain_steps function."""