Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

Submodule letsbe-orchestrator deleted from 21540e31c3

View File

@@ -0,0 +1,27 @@
{
"permissions": {
"allow": [
"mcp__serena__activate_project",
"mcp__serena__list_dir",
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs",
"mcp__zen__consensus",
"Bash(docker compose build:*)",
"Bash(docker compose:*)",
"Bash(curl:*)",
"Bash(dir \"Z:\\Repos\\LetsBeV2\\orchestrator\\letsbe-orchestrator\\alembic\\versions\" /B)",
"Bash(dir:*)",
"Bash(timeout:*)",
"Bash(python:*)",
"Bash(git init:*)",
"Bash(git remote add:*)",
"Bash(git add:*)",
"Bash(git commit -m \"$(cat <<''EOF''\nInitial commit: LetsBe Cloud Orchestrator\n\nFeatures:\n- FastAPI backend with SQLAlchemy 2.0 async ORM\n- Tenant management (CRUD operations)\n- Task management with types: FILE_WRITE, ENV_UPDATE, DOCKER_RELOAD, COMPOSITE\n- Agent registration, heartbeat, and task claiming (/tasks/next)\n- Chatwoot deployment playbook (COMPOSITE task with ENV_UPDATE + DOCKER_RELOAD)\n- Alembic migrations for Postgres\n- Docker Compose setup for local development\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git push:*)",
"Bash(git remote set-url:*)",
"Bash(git commit:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -0,0 +1,43 @@
# LetsBe Orchestrator Environment Variables
# Copy this file to .env and fill in the values
# =============================================================================
# ADMIN API KEY (Required for production)
# =============================================================================
# Used to authenticate admin endpoints like registration token management.
#
# Generate a secure key with:
# python -c "import secrets; print(secrets.token_hex(32))"
#
# Or using openssl:
# openssl rand -hex 32
#
ADMIN_API_KEY=change-me-in-production
# =============================================================================
# Database
# =============================================================================
# Port 5434 to avoid conflict with Hub Postgres (5432) and other services
DATABASE_URL=postgresql+asyncpg://orchestrator:orchestrator@localhost:5434/orchestrator
# Postgres password for docker-compose (required)
# Generate with: openssl rand -hex 16
POSTGRES_PASSWORD=change-me-in-production
# =============================================================================
# Application Settings
# =============================================================================
DEBUG=true
APP_NAME=LetsBe Orchestrator
# =============================================================================
# Hub Telemetry (optional)
# =============================================================================
# Send periodic health/metrics telemetry to the central Hub.
# Requires HUB_URL and HUB_API_KEY to be set.
#
# HUB_TELEMETRY_ENABLED=false
# HUB_URL=https://hub.letsbe.solutions
# HUB_API_KEY=
# Interval in seconds between telemetry submissions (min 10, max 600, default 60)
# HUB_TELEMETRY_INTERVAL_SECONDS=60

View File

@@ -0,0 +1,76 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
tags:
- 'v*'
pull_request:
branches:
- main
- master
env:
REGISTRY: code.letsbe.solutions
IMAGE_NAME: letsbe/orchestrator
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-asyncio aiosqlite
- name: Run tests
run: pytest -v --tb=short
build:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

64
letsbe-orchestrator/.gitignore vendored Normal file
View File

@@ -0,0 +1,64 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Testing
.tox/
.coverage
.coverage.*
htmlcov/
.pytest_cache/
.hypothesis/
# Mypy
.mypy_cache/
# Local development
*.log
*.sqlite
*.db
.env.test
# Docker
.docker/
# OS
.DS_Store
Thumbs.db
# Serena (local MCP tool data)
.serena/

View File

@@ -0,0 +1,64 @@
# CLAUDE.md — LetsBe Cloud Orchestrator
## Overview
You are the engineering assistant for the **LetsBe Cloud Orchestrator**, the core control-plane backend for the LetsBe Cloud platform.
The platform automatically provisions per-tenant servers, deploys a suite of open-source tools (Poste, Keycloak, MinIO, Passbolt, Vikunja, etc.), and coordinates an **AI SysAdmin agent** that configures those tools autonomously. The Orchestrator provides APIs for:
- Tenant lifecycle management
- Server provisioning state
- Task scheduling
- Agent registration & heartbeat
- Event logging
- DNS operations (via Entri integration)
- Secrets storage (via Vault)
This repository contains ONLY the **Orchestrator service**, not the SysAdmin agent.
Claude Code should generate code that is:
- Clean, strongly typed
- Production-ready
- Following Python 3.11 best practices
- Using FastAPI + SQLAlchemy + Alembic
- Modular, scalable, logically structured
---
## Project Goals
1. Provide a REST API for managing:
- Tenants
- Servers
- Tasks
- Agents
- Events
2. Integrate with:
- Postgres
- Vault (later)
- Entri DNS (later)
- SysAdmin agent (remote automation worker)
3. Serve as the **central state and coordination system** for the entire LetsBe Cloud platform.
---
## Tech Stack
**Backend framework:** FastAPI
**Language:** Python 3.11
**Database:** Postgres (via Docker Compose)
**ORM:** SQLAlchemy 2.0
**Migrations:** Alembic
**Serialization:** Pydantic v2
**Containerization:** Docker Compose
**Testing:** Pytest (later)
**Architecture style:** Modular monolith (service modules inside `app/`)
Everything MUST run via Docker using:
```bash
docker compose up --build
```

View File

@@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 8000
# Default command
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,103 @@
# Orchestrator Roadmap
This document tracks Orchestrator-specific work for the AI SysAdmin system.
## Completed Work
### Core Infrastructure
- [x] Task model, statuses, DB tables
- [x] Agent model + registration flow
- [x] Task dispatching (`/tasks/next` polling)
- [x] Heartbeat system
- [x] Result ingestion
- [x] Tenant validation, permission flow
- [x] FastAPI app with complete routing structure
- [x] Deployment on live server (nginx + SSL)
### API Routes
- [x] `/api/v1/agents/*` - Agent registration, heartbeat
- [x] `/api/v1/tasks/*` - Task creation, polling, results
- [x] `/api/v1/tenants/*` - Tenant management
- [x] `/api/v1/env/*` - ENV inspection and update
- [x] `/api/v1/agents/{id}/files/inspect` - File inspection
### Playbooks
- [x] Chatwoot playbook (`/api/v1/tenants/{id}/chatwoot/setup`)
---
## Remaining Work
### Phase 1: Tool-Specific Playbooks
Create a playbook module for each tool in `app/playbooks/`:
| Tool | Module | API Endpoint | Status |
|------|--------|--------------|--------|
| Chatwoot | `chatwoot.py` | `/tenants/{id}/chatwoot/setup` | ✅ Done |
| NocoDB | `nocodb.py` | `/tenants/{id}/nocodb/setup` | ⬚ Todo |
| Directus | `directus.py` | `/tenants/{id}/directus/setup` | ⬚ Todo |
| Ghost CMS | `ghost.py` | `/tenants/{id}/ghost/setup` | ⬚ Todo |
| MinIO | `minio.py` | `/tenants/{id}/minio/setup` | ⬚ Todo |
| Keycloak | `keycloak.py` | `/tenants/{id}/keycloak/setup` | ⬚ Todo |
| Nextcloud | `nextcloud.py` | `/tenants/{id}/nextcloud/setup` | ⬚ Todo |
| Activepieces | `activepieces.py` | `/tenants/{id}/activepieces/setup` | ⬚ Todo |
| Listmonk | `listmonk.py` | `/tenants/{id}/listmonk/setup` | ⬚ Todo |
| Odoo | `odoo.py` | `/tenants/{id}/odoo/setup` | ⬚ Todo |
| Mixpost | `mixpost.py` | `/tenants/{id}/mixpost/setup` | ⬚ Todo |
**Each playbook creates a COMPOSITE task with:**
1. `ENV_INSPECT` - Read current configuration
2. `ENV_UPDATE` - Update URLs, domains, settings
3. `DOCKER_RELOAD` - Restart the stack
**Each playbook module needs:**
- Pydantic request/response schemas
- Route handler in `app/routes/playbooks.py`
- Tests in `tests/test_playbooks/`
---
### Phase 2: Introspection APIs
- [ ] `/api/v1/servers/{id}/scan` - Discover all services and their state
- [ ] `/api/v1/servers/{id}/diagnose` - Find configuration issues
- [ ] `/api/v1/servers/{id}/health` - Aggregate health check
---
### Phase 3: New Task Types
Support for new executor types from the agent:
| Task Type | Purpose | Status |
|-----------|---------|--------|
| NGINX_RELOAD | Reload nginx after config changes | ⬚ Todo |
| HEALTHCHECK | Check service status | ⬚ Todo |
| STACK_HEALTH | Verify docker compose stack | ⬚ Todo |
---
### Phase 4: Autonomous Operations
- [ ] LLM integration for natural language commands
- [ ] Task chaining based on results
- [ ] Automatic remediation workflows
---
### Phase 5: Dashboard & UI
- [ ] Task history and logs viewer
- [ ] Agent status dashboard
- [ ] Playbook marketplace
- [ ] RBAC and multi-tenant UI
---
## Next Steps
1. Create `app/playbooks/nocodb.py`
2. Add route: `POST /api/v1/tenants/{tenant_id}/nocodb/setup`
3. Create COMPOSITE task with ENV_INSPECT, ENV_UPDATE, DOCKER_RELOAD
4. Write tests in `tests/test_playbooks/test_nocodb.py`

View File

@@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Database URL - will be overridden by env.py from environment variable
# Host port is 5433 to avoid conflict with existing Postgres instances
sqlalchemy.url = postgresql+asyncpg://orchestrator:orchestrator@localhost:5433/orchestrator
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -q
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1,94 @@
"""Alembic migration environment configuration for async SQLAlchemy."""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.config import settings
from app.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Override sqlalchemy.url with environment variable
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Run migrations with a connection."""
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async engine."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,110 @@
"""initial_schema
Revision ID: 4ca4b9958baf
Revises:
Create Date: 2025-12-02 18:50:17.377481
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '4ca4b9958baf'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenants',
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('domain', sa.String(length=255), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('domain')
)
op.create_index(op.f('ix_tenants_name'), 'tenants', ['name'], unique=True)
op.create_table('agents',
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('last_heartbeat', sa.DateTime(timezone=True), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_agents_tenant_id'), 'agents', ['tenant_id'], unique=False)
op.create_table('servers',
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('hostname', sa.String(length=255), nullable=False),
sa.Column('ip_address', sa.String(length=45), nullable=True),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_servers_tenant_id'), 'servers', ['tenant_id'], unique=False)
op.create_table('tasks',
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('agent_id', sa.Uuid(), nullable=True),
sa.Column('type', sa.String(length=100), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('result', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tasks_agent_id'), 'tasks', ['agent_id'], unique=False)
op.create_index(op.f('ix_tasks_status'), 'tasks', ['status'], unique=False)
op.create_index(op.f('ix_tasks_tenant_id'), 'tasks', ['tenant_id'], unique=False)
op.create_index(op.f('ix_tasks_type'), 'tasks', ['type'], unique=False)
op.create_table('events',
sa.Column('tenant_id', sa.Uuid(), nullable=False),
sa.Column('task_id', sa.Uuid(), nullable=True),
sa.Column('event_type', sa.String(length=100), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_events_created_at'), 'events', ['created_at'], unique=False)
op.create_index(op.f('ix_events_event_type'), 'events', ['event_type'], unique=False)
op.create_index(op.f('ix_events_task_id'), 'events', ['task_id'], unique=False)
op.create_index(op.f('ix_events_tenant_id'), 'events', ['tenant_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_events_tenant_id'), table_name='events')
op.drop_index(op.f('ix_events_task_id'), table_name='events')
op.drop_index(op.f('ix_events_event_type'), table_name='events')
op.drop_index(op.f('ix_events_created_at'), table_name='events')
op.drop_table('events')
op.drop_index(op.f('ix_tasks_type'), table_name='tasks')
op.drop_index(op.f('ix_tasks_tenant_id'), table_name='tasks')
op.drop_index(op.f('ix_tasks_status'), table_name='tasks')
op.drop_index(op.f('ix_tasks_agent_id'), table_name='tasks')
op.drop_table('tasks')
op.drop_index(op.f('ix_servers_tenant_id'), table_name='servers')
op.drop_table('servers')
op.drop_index(op.f('ix_agents_tenant_id'), table_name='agents')
op.drop_table('agents')
op.drop_index(op.f('ix_tenants_name'), table_name='tenants')
op.drop_table('tenants')
# ### end Alembic commands ###

View File

@@ -0,0 +1,48 @@
"""add_agent_fields_and_nullable_tenant
Revision ID: add_agent_fields
Revises: 4ca4b9958baf
Create Date: 2025-12-02 19:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_agent_fields'
down_revision: Union[str, None] = '4ca4b9958baf'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new columns to agents table
op.add_column('agents', sa.Column('version', sa.String(length=50), nullable=False, server_default=''))
op.add_column('agents', sa.Column('status', sa.String(length=20), nullable=False, server_default='offline'))
op.add_column('agents', sa.Column('token', sa.Text(), nullable=False, server_default=''))
# Create index on status for efficient queries
op.create_index(op.f('ix_agents_status'), 'agents', ['status'], unique=False)
# Make tenant_id nullable (agents can register without a tenant)
op.alter_column('agents', 'tenant_id',
existing_type=sa.UUID(),
nullable=True)
def downgrade() -> None:
# Make tenant_id NOT NULL again (will fail if there are rows with NULL tenant_id)
op.alter_column('agents', 'tenant_id',
existing_type=sa.UUID(),
nullable=False)
# Drop the status index
op.drop_index(op.f('ix_agents_status'), table_name='agents')
# Drop new columns
op.drop_column('agents', 'token')
op.drop_column('agents', 'status')
op.drop_column('agents', 'version')

View File

@@ -0,0 +1,36 @@
"""add_dashboard_token_hash_to_tenants
Revision ID: add_dashboard_token_hash
Revises: add_registration_tokens
Create Date: 2025-01-05 12:00:00.000000
This migration adds dashboard_token_hash column to tenants table
for authenticating dashboard-to-orchestrator communication.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_dashboard_token_hash'
down_revision: Union[str, None] = 'add_registration_tokens'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'tenants',
sa.Column(
'dashboard_token_hash',
sa.String(length=64),
nullable=True,
comment='SHA-256 hash of dashboard authentication token'
)
)
def downgrade() -> None:
op.drop_column('tenants', 'dashboard_token_hash')

View File

@@ -0,0 +1,94 @@
"""add_registration_tokens_and_agent_secret_hash
Revision ID: add_registration_tokens
Revises: add_agent_fields
Create Date: 2025-12-06 10:00:00.000000
This migration adds:
1. registration_tokens table for secure agent registration
2. secret_hash column to agents for new auth scheme
3. registration_token_id FK in agents to track token usage
"""
from typing import Sequence, Union
import hashlib
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'add_registration_tokens'
down_revision: Union[str, None] = 'add_agent_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Create registration_tokens table
op.create_table(
'registration_tokens',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False, comment='SHA-256 hash of the registration token'),
sa.Column('description', sa.String(length=255), nullable=True, comment='Human-readable description for the token'),
sa.Column('max_uses', sa.Integer(), nullable=False, server_default='1', comment='Maximum number of uses (0 = unlimited)'),
sa.Column('use_count', sa.Integer(), nullable=False, server_default='0', comment='Current number of times this token has been used'),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True, comment='Optional expiration timestamp'),
sa.Column('revoked', sa.Boolean(), nullable=False, server_default='false', comment='Whether this token has been manually revoked'),
sa.Column('created_by', sa.String(length=255), nullable=True, comment='Identifier of who created this token (for audit)'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
)
op.create_index(op.f('ix_registration_tokens_tenant_id'), 'registration_tokens', ['tenant_id'], unique=False)
op.create_index(op.f('ix_registration_tokens_token_hash'), 'registration_tokens', ['token_hash'], unique=False)
# 2. Add secret_hash column to agents (for new auth scheme)
# Initialize with empty string, will be populated during agent migration
op.add_column(
'agents',
sa.Column('secret_hash', sa.String(length=64), nullable=False, server_default='', comment='SHA-256 hash of the agent secret')
)
# 3. Add registration_token_id FK to agents
op.add_column(
'agents',
sa.Column('registration_token_id', sa.UUID(), nullable=True)
)
op.create_foreign_key(
'fk_agents_registration_token_id',
'agents', 'registration_tokens',
['registration_token_id'], ['id'],
ondelete='SET NULL'
)
op.create_index(op.f('ix_agents_registration_token_id'), 'agents', ['registration_token_id'], unique=False)
# 4. Migrate existing agent tokens to secret_hash
# For existing agents, we'll hash their current token and store it as secret_hash
# This allows backward compatibility during the transition period
connection = op.get_bind()
agents = connection.execute(sa.text("SELECT id, token FROM agents WHERE token != ''"))
for agent in agents:
if agent.token:
hashed = hashlib.sha256(agent.token.encode()).hexdigest()
connection.execute(
sa.text("UPDATE agents SET secret_hash = :hash WHERE id = :id"),
{"hash": hashed, "id": agent.id}
)
def downgrade() -> None:
# Drop registration_token_id FK and index from agents
op.drop_index(op.f('ix_agents_registration_token_id'), table_name='agents')
op.drop_constraint('fk_agents_registration_token_id', 'agents', type_='foreignkey')
op.drop_column('agents', 'registration_token_id')
# Drop secret_hash column from agents
op.drop_column('agents', 'secret_hash')
# Drop registration_tokens table
op.drop_index(op.f('ix_registration_tokens_token_hash'), table_name='registration_tokens')
op.drop_index(op.f('ix_registration_tokens_tenant_id'), table_name='registration_tokens')
op.drop_table('registration_tokens')

View File

@@ -0,0 +1 @@
# LetsBe Cloud Orchestrator

View File

@@ -0,0 +1,104 @@
"""Application configuration using Pydantic Settings."""
from functools import lru_cache
from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Database (port 5434 to avoid conflict with Hub Postgres and other services)
DATABASE_URL: str = "postgresql+asyncpg://orchestrator:orchestrator@localhost:5434/orchestrator"
# Application
DEBUG: bool = False
APP_NAME: str = "LetsBe Orchestrator"
APP_VERSION: str = "0.1.0"
# Connection pool settings
DB_POOL_SIZE: int = 5
DB_MAX_OVERFLOW: int = 10
DB_POOL_TIMEOUT: int = 30
DB_POOL_RECYCLE: int = 1800
# Authentication
# Admin API key for protected endpoints (registration token management)
# MUST be set via ADMIN_API_KEY environment variable
ADMIN_API_KEY: str = Field(
description="API key for admin endpoints. MUST be set via ADMIN_API_KEY env var.",
)
# ============================================================
# LOCAL MODE SETTINGS
# When LOCAL_MODE=true, orchestrator runs in single-tenant mode
# with automatic tenant bootstrap on startup.
# When LOCAL_MODE=false (default), multi-tenant behavior is unchanged.
# ============================================================
LOCAL_MODE: bool = Field(
default=False,
description="Enable single-tenant local mode. When true, auto-creates tenant on startup.",
)
# Instance identification (from Hub activation)
INSTANCE_ID: Optional[str] = Field(
default=None,
description="Unique instance identifier from Hub activation. Required in LOCAL_MODE.",
)
# Hub integration for telemetry (optional)
HUB_URL: Optional[str] = Field(
default=None,
description="Hub API URL for telemetry. Optional even in LOCAL_MODE.",
)
HUB_API_KEY: Optional[str] = Field(
default=None,
description="Hub API key for telemetry authentication. Required if HUB_URL is set.",
)
HUB_TELEMETRY_ENABLED: bool = Field(
default=False,
description="Whether to send telemetry to Hub. Requires HUB_URL and HUB_API_KEY.",
)
HUB_TELEMETRY_INTERVAL_SECONDS: int = Field(
default=60,
ge=10,
le=600,
description="Interval between telemetry submissions in seconds.",
)
# Local tenant settings (used when LOCAL_MODE=true)
LOCAL_TENANT_DOMAIN: str = Field(
default="local.letsbe.cloud",
description="Domain for auto-created local tenant.",
)
# CORS
CORS_ALLOWED_ORIGINS: str = Field(
default="",
description="Comma-separated list of allowed CORS origins. Empty disables CORS.",
)
# Dedicated key for local agent registration (Phase 2)
# More restrictive than ADMIN_API_KEY - can ONLY register the local agent
LOCAL_AGENT_KEY: Optional[str] = Field(
default=None,
description="Key for local agent registration. Required when LOCAL_MODE=true.",
)
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()
# For backward compatibility
settings = get_settings()

View File

@@ -0,0 +1,52 @@
"""Database configuration and session management."""
from collections.abc import AsyncGenerator
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.config import settings
# Create async engine with connection pooling
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DB_POOL_SIZE,
max_overflow=settings.DB_MAX_OVERFLOW,
pool_timeout=settings.DB_POOL_TIMEOUT,
pool_recycle=settings.DB_POOL_RECYCLE,
echo=settings.DEBUG,
)
# Create async session factory
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency that provides an async database session.
Yields a session and ensures proper cleanup via finally block.
"""
async with async_session_maker() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
# Type alias for dependency injection
AsyncSessionDep = Annotated[AsyncSession, Depends(get_db)]

View File

@@ -0,0 +1,19 @@
"""FastAPI dependencies for the Orchestrator."""
from app.dependencies.auth import (
CurrentAgentDep,
get_current_agent,
)
from app.dependencies.admin_auth import AdminAuthDep, verify_admin_api_key
from app.dependencies.dashboard_auth import DashboardAuthDep, verify_dashboard_token
from app.dependencies.local_agent_auth import verify_local_agent_key
__all__ = [
"CurrentAgentDep",
"get_current_agent",
"AdminAuthDep",
"verify_admin_api_key",
"DashboardAuthDep",
"verify_dashboard_token",
"verify_local_agent_key",
]

View File

@@ -0,0 +1,33 @@
"""Admin authentication dependency for protected endpoints."""
import secrets
from fastapi import Depends, Header, HTTPException, status
from app.config import get_settings
async def verify_admin_api_key(
x_admin_api_key: str = Header(..., alias="X-Admin-Api-Key"),
) -> None:
"""
Verify admin API key for protected endpoints.
Used to protect sensitive operations like registration token management.
Raises:
HTTPException: 401 if API key is missing or invalid
"""
settings = get_settings()
# Use timing-safe comparison to prevent timing attacks
if not secrets.compare_digest(x_admin_api_key, settings.ADMIN_API_KEY):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin API key",
headers={"WWW-Authenticate": "ApiKey"},
)
# Dependency that can be used in route decorators
AdminAuthDep = Depends(verify_admin_api_key)

View File

@@ -0,0 +1,65 @@
"""Agent authentication dependencies."""
import hashlib
import secrets
import uuid
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.models.agent import Agent
async def get_current_agent(
db: AsyncSessionDep,
x_agent_id: str = Header(..., alias="X-Agent-Id"),
x_agent_secret: str = Header(..., alias="X-Agent-Secret"),
) -> Agent:
"""
Validate agent credentials using X-Agent-Id/X-Agent-Secret headers.
Args:
db: Database session
x_agent_id: Agent UUID from X-Agent-Id header
x_agent_secret: Agent secret from X-Agent-Secret header
Returns:
Agent if credentials are valid
Raises:
HTTPException: 401 if credentials are invalid
"""
# Parse agent ID
try:
agent_id = uuid.UUID(x_agent_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Agent ID format",
)
# Look up agent
result = await db.execute(select(Agent).where(Agent.id == agent_id))
agent = result.scalar_one_or_none()
if agent is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid agent credentials",
)
# Verify secret using timing-safe comparison
provided_hash = hashlib.sha256(x_agent_secret.encode()).hexdigest()
if not secrets.compare_digest(agent.secret_hash, provided_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid agent credentials",
)
return agent
# Type alias for dependency injection
CurrentAgentDep = Annotated[Agent, Depends(get_current_agent)]

View File

@@ -0,0 +1,73 @@
"""Dashboard authentication dependency for tenant dashboard-to-orchestrator communication."""
import hashlib
import secrets
from typing import Annotated
from uuid import UUID
from fastapi import Depends, Header, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.models.tenant import Tenant
async def verify_dashboard_token(
tenant_id: Annotated[UUID, Path(...)],
x_dashboard_token: Annotated[str, Header(alias="X-Dashboard-Token")],
db: AsyncSession = Depends(get_db),
) -> Tenant:
"""
Verify per-tenant dashboard token for tenant dashboard endpoints.
The dashboard token is used by the tenant's dashboard application
(Hub Dashboard or Control Panel) to authenticate requests to the
Orchestrator for task execution and status queries.
Args:
tenant_id: The tenant UUID from the path
x_dashboard_token: The raw dashboard token from header
db: Database session
Returns:
The verified Tenant object
Raises:
HTTPException: 401 if token is missing, invalid, or tenant not found
"""
# Fetch tenant
result = await db.execute(
select(Tenant).where(Tenant.id == tenant_id)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
if not tenant.dashboard_token_hash:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Dashboard token not configured for this tenant",
headers={"WWW-Authenticate": "DashboardToken"},
)
# Compute SHA-256 hash of provided token
provided_hash = hashlib.sha256(x_dashboard_token.encode()).hexdigest()
# Use timing-safe comparison to prevent timing attacks
if not secrets.compare_digest(tenant.dashboard_token_hash, provided_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid dashboard token",
headers={"WWW-Authenticate": "DashboardToken"},
)
return tenant
# Type alias for dependency injection
DashboardAuthDep = Annotated[Tenant, Depends(verify_dashboard_token)]

View File

@@ -0,0 +1,49 @@
"""Local agent authentication dependency for LOCAL_MODE registration."""
import secrets
from fastapi import Header, HTTPException, status
from app.config import get_settings
async def verify_local_agent_key(
x_local_agent_key: str = Header(..., alias="X-Local-Agent-Key"),
) -> None:
"""
Verify LOCAL_AGENT_KEY for local agent registration.
This is a narrow-scope credential that can ONLY register the local agent.
It is NOT the same as ADMIN_API_KEY.
HTTP Status Codes:
- 404: LOCAL_MODE is false (endpoint hidden by design)
- 401: LOCAL_AGENT_KEY is missing, invalid, or not configured
Raises:
HTTPException: 404 if not in LOCAL_MODE, 401 if key is invalid
"""
settings = get_settings()
# Endpoint only exists in LOCAL_MODE (security by obscurity)
if not settings.LOCAL_MODE:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not found",
)
# LOCAL_AGENT_KEY must be configured
if not settings.LOCAL_AGENT_KEY:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Local agent key not configured",
headers={"WWW-Authenticate": "ApiKey"},
)
# Use timing-safe comparison to prevent timing attacks
if not secrets.compare_digest(x_local_agent_key, settings.LOCAL_AGENT_KEY):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid local agent key",
headers={"WWW-Authenticate": "ApiKey"},
)

View File

@@ -0,0 +1,163 @@
"""FastAPI application entry point."""
import logging
import uuid
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from sqlalchemy.exc import IntegrityError
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings
from app.db import engine
from app.routes import (
agents_router,
env_router,
events_router,
files_router,
health_router,
meta_router,
playbooks_router,
registration_tokens_router,
tasks_router,
tenants_router,
)
from app.services.hub_telemetry import HubTelemetryService
from app.services.local_bootstrap import LocalBootstrapService
logger = logging.getLogger(__name__)
# --- Middleware ---
class RequestIDMiddleware(BaseHTTPMiddleware):
"""Middleware that adds a unique request ID to each request."""
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
# --- Lifespan ---
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler for startup and shutdown."""
# Startup
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
logger.info(f"LOCAL_MODE={settings.LOCAL_MODE}")
# Run local bootstrap if LOCAL_MODE is enabled
# This is migration-safe: handles missing tables gracefully
if settings.LOCAL_MODE:
await LocalBootstrapService.run()
# Start Hub telemetry service (if enabled via HUB_TELEMETRY_ENABLED)
# This runs in background and never blocks startup
await HubTelemetryService.start()
yield
# Shutdown
logger.info("Shutting down...")
await HubTelemetryService.stop()
await engine.dispose()
# --- Application ---
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Control-plane backend for the LetsBe Cloud platform",
lifespan=lifespan,
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None,
)
# Rate limiting
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.middleware("http")
async def normalize_trailing_slashes(request: Request, call_next):
"""Strip trailing slashes from URLs to normalize routing."""
if request.url.path != "/" and request.url.path.endswith("/"):
# Modify the scope to remove trailing slash
request.scope["path"] = request.url.path.rstrip("/")
return await call_next(request)
# Add middleware
app.add_middleware(RequestIDMiddleware)
# Add CORS middleware if origins are configured
if settings.CORS_ALLOWED_ORIGINS:
origins = [o.strip() for o in settings.CORS_ALLOWED_ORIGINS.split(",")]
else:
origins = []
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["*"],
)
# --- Exception Handlers ---
@app.exception_handler(IntegrityError)
async def integrity_error_handler(request: Request, exc: IntegrityError) -> JSONResponse:
"""Handle database integrity errors (unique constraint violations, etc.)."""
return JSONResponse(
status_code=409,
content={
"detail": "Resource conflict: a record with these values already exists",
"request_id": getattr(request.state, "request_id", None),
},
)
# --- Routers ---
app.include_router(health_router)
app.include_router(meta_router, prefix="/api/v1")
app.include_router(tenants_router, prefix="/api/v1")
app.include_router(tasks_router, prefix="/api/v1")
app.include_router(agents_router, prefix="/api/v1")
app.include_router(playbooks_router, prefix="/api/v1")
app.include_router(env_router, prefix="/api/v1")
app.include_router(files_router, prefix="/api/v1")
app.include_router(registration_tokens_router, prefix="/api/v1")
app.include_router(events_router, prefix="/api/v1")
# --- Root endpoint ---
@app.get("/")
async def root():
"""Root endpoint redirecting to docs."""
return {
"message": f"Welcome to {settings.APP_NAME}",
"docs": "/docs",
"health": "/health",
}

View File

@@ -0,0 +1,21 @@
"""SQLAlchemy models for the Orchestrator."""
from app.models.base import Base
from app.models.tenant import Tenant
from app.models.server import Server
from app.models.task import Task, TaskStatus
from app.models.agent import Agent, AgentStatus
from app.models.event import Event
from app.models.registration_token import RegistrationToken
__all__ = [
"Base",
"Tenant",
"Server",
"Task",
"TaskStatus",
"Agent",
"AgentStatus",
"Event",
"RegistrationToken",
]

View File

@@ -0,0 +1,92 @@
"""Agent model for SysAdmin automation workers."""
import uuid
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.registration_token import RegistrationToken
from app.models.task import Task
from app.models.tenant import Tenant
class AgentStatus(str, Enum):
"""Agent status values."""
ONLINE = "online"
OFFLINE = "offline"
INVALID = "invalid" # Agent with NULL tenant_id, must re-register
class Agent(UUIDMixin, TimestampMixin, Base):
"""
Agent model representing a SysAdmin automation worker.
Agents register with the orchestrator and receive tasks to execute.
"""
__tablename__ = "agents"
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
version: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="",
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default=AgentStatus.OFFLINE.value,
index=True,
)
last_heartbeat: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# Legacy field - kept for backward compatibility during migration
# Will be removed after all agents migrate to new auth scheme
token: Mapped[str] = mapped_column(
Text,
nullable=False,
default="",
)
# New secure credential storage - SHA-256 hash of agent secret
secret_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
default="",
comment="SHA-256 hash of the agent secret",
)
# Reference to the registration token used to create this agent
registration_token_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("registration_tokens.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# Relationships
tenant: Mapped["Tenant | None"] = relationship(
back_populates="agents",
)
tasks: Mapped[list["Task"]] = relationship(
back_populates="agent",
lazy="selectin",
)
registration_token: Mapped["RegistrationToken | None"] = relationship()
def __repr__(self) -> str:
return f"<Agent(id={self.id}, name={self.name}, status={self.status})>"

View File

@@ -0,0 +1,44 @@
"""Base model and mixins for SQLAlchemy ORM."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def utc_now() -> datetime:
"""Return current UTC datetime."""
return datetime.now(timezone.utc)
class Base(AsyncAttrs, DeclarativeBase):
"""Base class for all SQLAlchemy models."""
pass
class UUIDMixin:
"""Mixin that adds a UUID primary key."""
id: Mapped[uuid.UUID] = mapped_column(
primary_key=True,
default=uuid.uuid4,
)
class TimestampMixin:
"""Mixin that adds created_at and updated_at timestamps."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
onupdate=utc_now,
nullable=False,
)

View File

@@ -0,0 +1,72 @@
"""Event model for audit logging."""
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from sqlalchemy import DateTime, ForeignKey, JSON, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
JSONType = JSON().with_variant(JSONB, "postgresql")
from app.models.base import Base, UUIDMixin
if TYPE_CHECKING:
from app.models.task import Task
from app.models.tenant import Tenant
def utc_now() -> datetime:
"""Return current UTC datetime."""
return datetime.now(timezone.utc)
class Event(UUIDMixin, Base):
"""
Event model for audit logging and activity tracking.
Events are immutable records of system activity.
Only has created_at (no updated_at since events are immutable).
"""
__tablename__ = "events"
tenant_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
event_type: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True,
)
payload: Mapped[dict[str, Any]] = mapped_column(
JSONType,
nullable=False,
default=dict,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
nullable=False,
index=True,
)
# Relationships
tenant: Mapped["Tenant"] = relationship(
back_populates="events",
)
task: Mapped["Task | None"] = relationship(
back_populates="events",
)
def __repr__(self) -> str:
return f"<Event(id={self.id}, type={self.event_type})>"

View File

@@ -0,0 +1,101 @@
"""Registration token model for secure agent registration."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.tenant import Tenant
class RegistrationToken(UUIDMixin, TimestampMixin, Base):
"""
Registration token for secure agent registration.
Tokens are pre-provisioned by admins and map to specific tenants.
Agents use these tokens during initial registration to:
1. Authenticate the registration request
2. Associate themselves with the correct tenant
Tokens can be:
- Single-use (max_uses=1, default)
- Limited-use (max_uses > 1)
- Unlimited (max_uses=0)
- Time-limited (expires_at set)
- Manually revoked (revoked=True)
"""
__tablename__ = "registration_tokens"
tenant_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
token_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
index=True,
comment="SHA-256 hash of the registration token",
)
description: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
comment="Human-readable description for the token",
)
max_uses: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
comment="Maximum number of uses (0 = unlimited)",
)
use_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="Current number of times this token has been used",
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Optional expiration timestamp",
)
revoked: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="Whether this token has been manually revoked",
)
created_by: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
comment="Identifier of who created this token (for audit)",
)
# Relationships
tenant: Mapped["Tenant"] = relationship(
back_populates="registration_tokens",
)
def __repr__(self) -> str:
return f"<RegistrationToken(id={self.id}, tenant_id={self.tenant_id}, uses={self.use_count}/{self.max_uses})>"
def is_valid(self, now: datetime | None = None) -> bool:
"""Check if the token can still be used for registration."""
from app.models.base import utc_now
if now is None:
now = utc_now()
if self.revoked:
return False
if self.expires_at is not None and self.expires_at < now:
return False
if self.max_uses > 0 and self.use_count >= self.max_uses:
return False
return True

View File

@@ -0,0 +1,59 @@
"""Server model for provisioned infrastructure."""
import uuid
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.tenant import Tenant
class ServerStatus(str, Enum):
"""Server provisioning status."""
PROVISIONING = "provisioning"
READY = "ready"
ERROR = "error"
TERMINATED = "terminated"
class Server(UUIDMixin, TimestampMixin, Base):
"""
Server model representing a provisioned VM or container.
Tracks provisioning state and network configuration.
"""
__tablename__ = "servers"
tenant_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
hostname: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
ip_address: Mapped[str | None] = mapped_column(
String(45), # Supports IPv6
nullable=True,
)
status: Mapped[str] = mapped_column(
String(50),
default=ServerStatus.PROVISIONING.value,
nullable=False,
)
# Relationships
tenant: Mapped["Tenant"] = relationship(
back_populates="servers",
)
def __repr__(self) -> str:
return f"<Server(id={self.id}, hostname={self.hostname}, status={self.status})>"

View File

@@ -0,0 +1,85 @@
"""Task model for orchestration jobs."""
import uuid
from enum import Enum
from typing import TYPE_CHECKING, Any
from sqlalchemy import ForeignKey, JSON, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
JSONType = JSON().with_variant(JSONB, "postgresql")
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.agent import Agent
from app.models.event import Event
from app.models.tenant import Tenant
class TaskStatus(str, Enum):
"""Task execution status."""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class Task(UUIDMixin, TimestampMixin, Base):
"""
Task model representing an orchestration job.
Tasks are assigned to agents and track execution state.
Payload and result use JSONB for flexible, queryable storage.
"""
__tablename__ = "tasks"
tenant_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
agent_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
type: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True,
)
payload: Mapped[dict[str, Any]] = mapped_column(
JSONType,
nullable=False,
default=dict,
)
status: Mapped[str] = mapped_column(
String(50),
default=TaskStatus.PENDING.value,
nullable=False,
index=True,
)
result: Mapped[dict[str, Any] | None] = mapped_column(
JSONType,
nullable=True,
)
# Relationships
tenant: Mapped["Tenant"] = relationship(
back_populates="tasks",
)
agent: Mapped["Agent | None"] = relationship(
back_populates="tasks",
)
events: Mapped[list["Event"]] = relationship(
back_populates="task",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Task(id={self.id}, type={self.type}, status={self.status})>"

View File

@@ -0,0 +1,67 @@
"""Tenant model for multi-tenancy support."""
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.agent import Agent
from app.models.event import Event
from app.models.registration_token import RegistrationToken
from app.models.server import Server
from app.models.task import Task
class Tenant(UUIDMixin, TimestampMixin, Base):
"""
Tenant model representing a customer organization.
Each tenant has isolated servers, tasks, agents, and events.
"""
__tablename__ = "tenants"
name: Mapped[str] = mapped_column(
String(255),
unique=True,
nullable=False,
index=True,
)
domain: Mapped[str | None] = mapped_column(
String(255),
unique=True,
nullable=True,
)
dashboard_token_hash: Mapped[str | None] = mapped_column(
String(64), # SHA-256 hex = 64 characters
nullable=True,
comment="SHA-256 hash of dashboard authentication token",
)
# Relationships
servers: Mapped[list["Server"]] = relationship(
back_populates="tenant",
lazy="selectin",
)
tasks: Mapped[list["Task"]] = relationship(
back_populates="tenant",
lazy="selectin",
)
agents: Mapped[list["Agent"]] = relationship(
back_populates="tenant",
lazy="selectin",
)
events: Mapped[list["Event"]] = relationship(
back_populates="tenant",
lazy="selectin",
)
registration_tokens: Mapped[list["RegistrationToken"]] = relationship(
back_populates="tenant",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Tenant(id={self.id}, name={self.name})>"

View File

@@ -0,0 +1,74 @@
"""Playbooks module for infrastructure automation tasks.
Playbooks define reusable sequences of steps (COMPOSITE tasks) for
deploying and configuring services on tenant servers.
"""
from app.playbooks.chatwoot import (
CompositeStep,
build_chatwoot_setup_steps,
create_chatwoot_setup_task,
)
from app.playbooks.nextcloud import (
build_nextcloud_set_domain_steps,
create_nextcloud_set_domain_task,
)
from app.playbooks.keycloak import (
build_keycloak_setup_steps,
create_keycloak_setup_task,
)
from app.playbooks.n8n import (
build_n8n_setup_steps,
create_n8n_setup_task,
)
from app.playbooks.calcom import (
build_calcom_setup_steps,
create_calcom_setup_task,
)
from app.playbooks.umami import (
build_umami_setup_steps,
create_umami_setup_task,
)
from app.playbooks.uptime_kuma import (
build_uptime_kuma_setup_steps,
create_uptime_kuma_setup_task,
)
from app.playbooks.vaultwarden import (
build_vaultwarden_setup_steps,
create_vaultwarden_setup_task,
)
from app.playbooks.portainer import (
build_portainer_setup_steps,
create_portainer_setup_task,
)
__all__ = [
"CompositeStep",
# Chatwoot
"build_chatwoot_setup_steps",
"create_chatwoot_setup_task",
# Nextcloud
"build_nextcloud_set_domain_steps",
"create_nextcloud_set_domain_task",
# Keycloak
"build_keycloak_setup_steps",
"create_keycloak_setup_task",
# n8n
"build_n8n_setup_steps",
"create_n8n_setup_task",
# Cal.com
"build_calcom_setup_steps",
"create_calcom_setup_task",
# Umami
"build_umami_setup_steps",
"create_umami_setup_task",
# Uptime Kuma
"build_uptime_kuma_setup_steps",
"create_uptime_kuma_setup_task",
# Vaultwarden
"build_vaultwarden_setup_steps",
"create_vaultwarden_setup_task",
# Portainer
"build_portainer_setup_steps",
"create_portainer_setup_task",
]

View File

@@ -0,0 +1,207 @@
"""Cal.com scheduling deployment playbook.
Defines the steps required to:
1. Set up Cal.com on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
2. Perform initial setup via Playwright automation (create admin account)
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
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
CALCOM_ENV_PATH = "/opt/letsbe/env/calcom.env"
CALCOM_STACK_DIR = "/opt/letsbe/stacks/calcom"
def build_calcom_setup_steps(*, domain: str) -> list[CompositeStep]:
"""
Build the sequence of steps required to set up Cal.com.
Assumes the env file already exists at /opt/letsbe/env/calcom.env
(created by provisioning/env_setup.sh).
Args:
domain: The domain for Cal.com (e.g., "cal.example.com")
Returns:
List of 2 CompositeStep objects:
1. ENV_UPDATE - patches NEXT_PUBLIC_WEBAPP_URL, NEXTAUTH_URL
2. DOCKER_RELOAD - restarts the calcom stack with pull=True
"""
steps = [
# Step 1: Update environment variables
CompositeStep(
type="ENV_UPDATE",
payload={
"path": CALCOM_ENV_PATH,
"updates": {
"NEXT_PUBLIC_WEBAPP_URL": f"https://{domain}",
"NEXTAUTH_URL": f"https://{domain}",
},
},
),
# Step 2: Reload Docker stack
CompositeStep(
type="DOCKER_RELOAD",
payload={
"compose_dir": CALCOM_STACK_DIR,
"pull": True,
},
),
]
return steps
async def create_calcom_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID | None,
domain: str,
) -> Task:
"""
Create and persist a COMPOSITE task for Cal.com setup.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: Optional UUID of the agent to assign the task to
domain: The domain for Cal.com
Returns:
The created Task object with type="COMPOSITE"
"""
steps = build_calcom_setup_steps(domain=domain)
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
# =============================================================================
# Initial Setup via Playwright
# =============================================================================
def build_calcom_initial_setup_step(
*,
base_url: str,
admin_email: str,
admin_password: str | None = None,
admin_username: str = "admin",
admin_name: str = "Admin",
) -> dict[str, Any]:
"""
Build a PLAYWRIGHT task payload for Cal.com initial setup.
This creates the admin account on a fresh Cal.com installation.
Args:
base_url: The base URL for Cal.com (e.g., "https://cal.example.com")
admin_email: Email address for the admin account
admin_password: Password for admin (auto-generated if None)
admin_username: Username for the admin account
admin_name: Display name for the admin account
Returns:
Task payload dict with type="PLAYWRIGHT"
"""
parsed = urlparse(base_url)
allowed_domain = parsed.netloc
inputs: dict[str, Any] = {
"base_url": base_url,
"admin_email": admin_email,
"admin_username": admin_username,
"admin_name": admin_name,
}
if admin_password:
inputs["admin_password"] = admin_password
return {
"scenario": "calcom_initial_setup",
"inputs": inputs,
"options": {
"allowed_domains": [allowed_domain],
},
"timeout": 120,
}
async def create_calcom_initial_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
base_url: str,
admin_email: str,
admin_password: str | None = None,
admin_username: str = "admin",
admin_name: str = "Admin",
) -> Task:
"""
Create and persist a PLAYWRIGHT task for Cal.com 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 Cal.com
admin_email: Email address for the admin account
admin_password: Password for admin (auto-generated if None)
admin_username: Username for the admin account
admin_name: Display name for the admin account
Returns:
The created Task object with type="PLAYWRIGHT"
"""
payload = build_calcom_initial_setup_step(
base_url=base_url,
admin_email=admin_email,
admin_password=admin_password,
admin_username=admin_username,
admin_name=admin_name,
)
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

View File

@@ -0,0 +1,207 @@
"""Chatwoot deployment playbook.
Defines the steps required to set up Chatwoot on a tenant server
that already has stacks and env templates under /opt/letsbe.
"""
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
CHATWOOT_ENV_PATH = "/opt/letsbe/env/chatwoot.env"
CHATWOOT_STACK_DIR = "/opt/letsbe/stacks/chatwoot"
def build_chatwoot_setup_steps(*, domain: str) -> list[CompositeStep]:
"""
Build the sequence of steps required to set up Chatwoot.
Assumes the env file already exists at /opt/letsbe/env/chatwoot.env
(created by provisioning/env_setup.sh).
Args:
domain: The domain for Chatwoot (e.g., "support.example.com")
Returns:
List of 2 CompositeStep objects:
1. ENV_UPDATE - patches FRONTEND_URL and BACKEND_URL
2. DOCKER_RELOAD - restarts the chatwoot stack with pull=True
"""
steps = [
# Step 1: Update environment variables
CompositeStep(
type="ENV_UPDATE",
payload={
"path": CHATWOOT_ENV_PATH,
"updates": {
"FRONTEND_URL": f"https://{domain}",
"BACKEND_URL": f"https://{domain}",
},
},
),
# Step 2: Reload Docker stack
CompositeStep(
type="DOCKER_RELOAD",
payload={
"compose_dir": CHATWOOT_STACK_DIR,
"pull": True,
},
),
]
return steps
async def create_chatwoot_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID | None,
domain: str,
) -> Task:
"""
Create and persist a COMPOSITE task for Chatwoot setup.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: Optional UUID of the agent to assign the task to
domain: The domain for Chatwoot
Returns:
The created Task object with type="COMPOSITE"
"""
steps = build_chatwoot_setup_steps(domain=domain)
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
# =============================================================================
# Initial Setup via Playwright
# =============================================================================
def build_chatwoot_initial_setup_step(
*,
base_url: str,
admin_name: str,
company_name: str,
admin_email: str,
admin_password: str | None = None,
) -> dict[str, Any]:
"""
Build a PLAYWRIGHT task payload for Chatwoot initial setup.
This creates the super admin account on a fresh Chatwoot installation.
Args:
base_url: The base URL for Chatwoot (e.g., "https://chatwoot.example.com")
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 None)
Returns:
Task payload dict with type="PLAYWRIGHT"
"""
from urllib.parse import urlparse
# Extract domain from URL for allowlist
parsed = urlparse(base_url)
allowed_domain = parsed.netloc
inputs: dict[str, Any] = {
"base_url": base_url,
"admin_name": admin_name,
"company_name": company_name,
"admin_email": admin_email,
}
# Only include password if provided
if admin_password:
inputs["admin_password"] = admin_password
return {
"scenario": "chatwoot_initial_setup",
"inputs": inputs,
"options": {
"allowed_domains": [allowed_domain],
},
"timeout": 120,
}
async def create_chatwoot_initial_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
base_url: str,
admin_name: str,
company_name: str,
admin_email: str,
admin_password: str | None = None,
) -> Task:
"""
Create and persist a PLAYWRIGHT task for Chatwoot 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 Chatwoot
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 None)
Returns:
The created Task object with type="PLAYWRIGHT"
"""
payload = build_chatwoot_initial_setup_step(
base_url=base_url,
admin_name=admin_name,
company_name=company_name,
admin_email=admin_email,
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

View File

@@ -0,0 +1,214 @@
"""Keycloak SSO deployment playbook.
Defines the steps required to:
1. Set up Keycloak on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
2. Perform initial setup via Playwright automation (create admin, configure realm)
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
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
KEYCLOAK_ENV_PATH = "/opt/letsbe/env/keycloak.env"
KEYCLOAK_STACK_DIR = "/opt/letsbe/stacks/keycloak"
def build_keycloak_setup_steps(
*,
domain: str,
admin_user: str = "admin",
admin_password: str,
) -> list[CompositeStep]:
"""
Build the sequence of steps required to set up Keycloak.
Assumes the env file already exists at /opt/letsbe/env/keycloak.env
(created by provisioning/env_setup.sh).
Args:
domain: The domain for Keycloak (e.g., "auth.example.com")
admin_user: Admin username (default: "admin")
admin_password: Admin password
Returns:
List of 2 CompositeStep objects:
1. ENV_UPDATE - patches KC_HOSTNAME, KEYCLOAK_ADMIN, KEYCLOAK_ADMIN_PASSWORD
2. DOCKER_RELOAD - restarts the keycloak stack with pull=True
"""
steps = [
# Step 1: Update environment variables
CompositeStep(
type="ENV_UPDATE",
payload={
"path": KEYCLOAK_ENV_PATH,
"updates": {
"KC_HOSTNAME": domain,
"KEYCLOAK_ADMIN": admin_user,
"KEYCLOAK_ADMIN_PASSWORD": admin_password,
},
},
),
# Step 2: Reload Docker stack
CompositeStep(
type="DOCKER_RELOAD",
payload={
"compose_dir": KEYCLOAK_STACK_DIR,
"pull": True,
},
),
]
return steps
async def create_keycloak_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID | None,
domain: str,
admin_user: str = "admin",
admin_password: str,
) -> Task:
"""
Create and persist a COMPOSITE task for Keycloak setup.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: Optional UUID of the agent to assign the task to
domain: The domain for Keycloak
admin_user: Admin username
admin_password: Admin password
Returns:
The created Task object with type="COMPOSITE"
"""
steps = build_keycloak_setup_steps(
domain=domain,
admin_user=admin_user,
admin_password=admin_password,
)
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
# =============================================================================
# Initial Setup via Playwright
# =============================================================================
def build_keycloak_initial_setup_step(
*,
base_url: str,
admin_user: str,
admin_password: str,
realm_name: str = "letsbe",
) -> dict[str, Any]:
"""
Build a PLAYWRIGHT task payload for Keycloak initial setup.
This creates the admin account and configures the "letsbe" realm
on a fresh Keycloak installation.
Args:
base_url: The base URL for Keycloak (e.g., "https://auth.example.com")
admin_user: Username for the admin account
admin_password: Password for the admin account
realm_name: Name of the realm to create (default: "letsbe")
Returns:
Task payload dict with type="PLAYWRIGHT"
"""
parsed = urlparse(base_url)
allowed_domain = parsed.netloc
return {
"scenario": "keycloak_initial_setup",
"inputs": {
"base_url": base_url,
"admin_user": admin_user,
"admin_password": admin_password,
"realm_name": realm_name,
},
"options": {
"allowed_domains": [allowed_domain],
},
"timeout": 120,
}
async def create_keycloak_initial_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
base_url: str,
admin_user: str,
admin_password: str,
realm_name: str = "letsbe",
) -> Task:
"""
Create and persist a PLAYWRIGHT task for Keycloak 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 Keycloak
admin_user: Username for the admin account
admin_password: Password for the admin account
realm_name: Name of the realm to create
Returns:
The created Task object with type="PLAYWRIGHT"
"""
payload = build_keycloak_initial_setup_step(
base_url=base_url,
admin_user=admin_user,
admin_password=admin_password,
realm_name=realm_name,
)
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

View File

@@ -0,0 +1,208 @@
"""n8n workflow automation deployment playbook.
Defines the steps required to:
1. Set up n8n on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
2. Perform initial setup via Playwright automation (create owner account)
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
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
N8N_ENV_PATH = "/opt/letsbe/env/n8n.env"
N8N_STACK_DIR = "/opt/letsbe/stacks/n8n"
def build_n8n_setup_steps(*, domain: str) -> list[CompositeStep]:
"""
Build the sequence of steps required to set up n8n.
Assumes the env file already exists at /opt/letsbe/env/n8n.env
(created by provisioning/env_setup.sh).
Args:
domain: The domain for n8n (e.g., "n8n.example.com")
Returns:
List of 2 CompositeStep objects:
1. ENV_UPDATE - patches N8N_HOST, N8N_PROTOCOL, WEBHOOK_URL
2. DOCKER_RELOAD - restarts the n8n stack with pull=True
"""
steps = [
# Step 1: Update environment variables
CompositeStep(
type="ENV_UPDATE",
payload={
"path": N8N_ENV_PATH,
"updates": {
"N8N_HOST": domain,
"N8N_PROTOCOL": "https",
"WEBHOOK_URL": f"https://{domain}/",
},
},
),
# Step 2: Reload Docker stack
CompositeStep(
type="DOCKER_RELOAD",
payload={
"compose_dir": N8N_STACK_DIR,
"pull": True,
},
),
]
return steps
async def create_n8n_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID | None,
domain: str,
) -> Task:
"""
Create and persist a COMPOSITE task for n8n setup.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: Optional UUID of the agent to assign the task to
domain: The domain for n8n
Returns:
The created Task object with type="COMPOSITE"
"""
steps = build_n8n_setup_steps(domain=domain)
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
# =============================================================================
# Initial Setup via Playwright
# =============================================================================
def build_n8n_initial_setup_step(
*,
base_url: str,
admin_email: str,
admin_password: str | None = None,
admin_first_name: str = "Admin",
admin_last_name: str = "User",
) -> dict[str, Any]:
"""
Build a PLAYWRIGHT task payload for n8n initial setup.
This creates the owner account on a fresh n8n installation.
Args:
base_url: The base URL for n8n (e.g., "https://n8n.example.com")
admin_email: Email address for the owner account
admin_password: Password for owner (auto-generated if None)
admin_first_name: First name for the owner account
admin_last_name: Last name for the owner account
Returns:
Task payload dict with type="PLAYWRIGHT"
"""
parsed = urlparse(base_url)
allowed_domain = parsed.netloc
inputs: dict[str, Any] = {
"base_url": base_url,
"admin_email": admin_email,
"admin_first_name": admin_first_name,
"admin_last_name": admin_last_name,
}
if admin_password:
inputs["admin_password"] = admin_password
return {
"scenario": "n8n_initial_setup",
"inputs": inputs,
"options": {
"allowed_domains": [allowed_domain],
},
"timeout": 120,
}
async def create_n8n_initial_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
base_url: str,
admin_email: str,
admin_password: str | None = None,
admin_first_name: str = "Admin",
admin_last_name: str = "User",
) -> Task:
"""
Create and persist a PLAYWRIGHT task for n8n 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 n8n
admin_email: Email address for the owner account
admin_password: Password for owner (auto-generated if None)
admin_first_name: First name for the owner account
admin_last_name: Last name for the owner account
Returns:
The created Task object with type="PLAYWRIGHT"
"""
payload = build_n8n_initial_setup_step(
base_url=base_url,
admin_email=admin_email,
admin_password=admin_password,
admin_first_name=admin_first_name,
admin_last_name=admin_last_name,
)
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

View File

@@ -0,0 +1,192 @@
"""Nextcloud deployment playbook.
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)
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
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"
# =============================================================================
# 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,
},
"options": {
"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).
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

@@ -0,0 +1,105 @@
"""Portainer container management deployment playbook.
Defines the steps required to set up Portainer on a tenant server
(ENV_UPDATE + DOCKER_RELOAD). No Playwright setup needed - Portainer's
admin account is created via its first-use web UI which is already
handled during provisioning.
Tenant servers must have stacks and env templates under /opt/letsbe.
"""
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
PORTAINER_ENV_PATH = "/opt/letsbe/env/portainer.env"
PORTAINER_STACK_DIR = "/opt/letsbe/stacks/portainer"
def build_portainer_setup_steps(*, domain: str) -> list[CompositeStep]:
"""
Build the sequence of steps required to set up Portainer.
Assumes the env file already exists at /opt/letsbe/env/portainer.env
(created by provisioning/env_setup.sh).
Args:
domain: The domain for Portainer (e.g., "portainer.example.com")
Returns:
List of 2 CompositeStep objects:
1. ENV_UPDATE - patches PORTAINER_DOMAIN
2. DOCKER_RELOAD - restarts the portainer stack with pull=True
"""
steps = [
# Step 1: Update environment variables
CompositeStep(
type="ENV_UPDATE",
payload={
"path": PORTAINER_ENV_PATH,
"updates": {
"PORTAINER_DOMAIN": domain,
},
},
),
# Step 2: Reload Docker stack
CompositeStep(
type="DOCKER_RELOAD",
payload={
"compose_dir": PORTAINER_STACK_DIR,
"pull": True,
},
),
]
return steps
async def create_portainer_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID | None,
domain: str,
) -> Task:
"""
Create and persist a COMPOSITE task for Portainer setup.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: Optional UUID of the agent to assign the task to
domain: The domain for Portainer
Returns:
The created Task object with type="COMPOSITE"
"""
steps = build_portainer_setup_steps(domain=domain)
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

@@ -0,0 +1,111 @@
"""Poste.io mail server deployment playbook.
Defines the steps required to:
1. Perform initial setup via Playwright automation (configure hostname, create admin account)
Tenant servers must have stacks and env templates under /opt/letsbe.
"""
import uuid
from typing import Any
from urllib.parse import urlparse
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.task import Task, TaskStatus
# LetsBe standard paths
POSTE_STACK_DIR = "/opt/letsbe/stacks/poste"
# =============================================================================
# Initial Setup via Playwright
# =============================================================================
def build_poste_initial_setup_step(
*,
base_url: str,
admin_email: str,
admin_password: str | None = None,
) -> dict[str, Any]:
"""
Build a PLAYWRIGHT task payload for Poste.io initial setup.
This configures the mail server hostname and creates the admin account
on a fresh Poste.io installation.
Args:
base_url: The base URL for Poste.io (e.g., "https://mail.example.com")
admin_email: Email address for the admin account (e.g., admin@example.com)
admin_password: Password for the admin account (auto-generated if None)
Returns:
Task payload dict with type="PLAYWRIGHT"
"""
# Extract domain from URL for allowlist
parsed = urlparse(base_url)
allowed_domain = parsed.netloc # e.g., "mail.example.com"
inputs: dict[str, Any] = {
"base_url": base_url,
"admin_email": admin_email,
}
# Only include password if provided - scenario will auto-generate if missing
if admin_password:
inputs["admin_password"] = admin_password
return {
"scenario": "poste_initial_setup",
"inputs": inputs,
"options": {
"allowed_domains": [allowed_domain],
},
"timeout": 120,
}
async def create_poste_initial_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
base_url: str,
admin_email: str,
admin_password: str | None = None,
) -> Task:
"""
Create and persist a PLAYWRIGHT task for Poste.io 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 Poste.io
admin_email: Email address for the admin account
admin_password: Password for admin (auto-generated if None)
Returns:
The created Task object with type="PLAYWRIGHT"
"""
payload = build_poste_initial_setup_step(
base_url=base_url,
admin_email=admin_email,
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

View File

@@ -0,0 +1,205 @@
"""Umami analytics deployment playbook.
Defines the steps required to:
1. Set up Umami on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
2. Perform initial setup via Playwright automation (create admin, add first website)
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
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
UMAMI_ENV_PATH = "/opt/letsbe/env/umami.env"
UMAMI_STACK_DIR = "/opt/letsbe/stacks/umami"
def build_umami_setup_steps(*, domain: str) -> list[CompositeStep]:
"""
Build the sequence of steps required to set up Umami.
Assumes the env file already exists at /opt/letsbe/env/umami.env
(created by provisioning/env_setup.sh).
Args:
domain: The domain for Umami (e.g., "analytics.example.com")
Returns:
List of 2 CompositeStep objects:
1. ENV_UPDATE - patches APP_URL
2. DOCKER_RELOAD - restarts the umami stack with pull=True
"""
steps = [
# Step 1: Update environment variables
CompositeStep(
type="ENV_UPDATE",
payload={
"path": UMAMI_ENV_PATH,
"updates": {
"APP_URL": f"https://{domain}",
},
},
),
# Step 2: Reload Docker stack
CompositeStep(
type="DOCKER_RELOAD",
payload={
"compose_dir": UMAMI_STACK_DIR,
"pull": True,
},
),
]
return steps
async def create_umami_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID | None,
domain: str,
) -> Task:
"""
Create and persist a COMPOSITE task for Umami setup.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: Optional UUID of the agent to assign the task to
domain: The domain for Umami
Returns:
The created Task object with type="COMPOSITE"
"""
steps = build_umami_setup_steps(domain=domain)
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
# =============================================================================
# Initial Setup via Playwright
# =============================================================================
def build_umami_initial_setup_step(
*,
base_url: str,
admin_password: str | None = None,
website_name: str | None = None,
website_url: str | None = None,
) -> dict[str, Any]:
"""
Build a PLAYWRIGHT task payload for Umami initial setup.
This logs in with default credentials, changes the admin password,
and optionally adds the first website to track.
Umami ships with default credentials: admin / umami
Args:
base_url: The base URL for Umami (e.g., "https://analytics.example.com")
admin_password: New password for admin (auto-generated if None)
website_name: Optional name of the first website to add
website_url: Optional URL of the first website to track
Returns:
Task payload dict with type="PLAYWRIGHT"
"""
parsed = urlparse(base_url)
allowed_domain = parsed.netloc
inputs: dict[str, Any] = {
"base_url": base_url,
}
if admin_password:
inputs["admin_password"] = admin_password
if website_name:
inputs["website_name"] = website_name
if website_url:
inputs["website_url"] = website_url
return {
"scenario": "umami_initial_setup",
"inputs": inputs,
"options": {
"allowed_domains": [allowed_domain],
},
"timeout": 120,
}
async def create_umami_initial_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
base_url: str,
admin_password: str | None = None,
website_name: str | None = None,
website_url: str | None = None,
) -> Task:
"""
Create and persist a PLAYWRIGHT task for Umami 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 Umami
admin_password: New password for admin (auto-generated if None)
website_name: Optional name of the first website to add
website_url: Optional URL of the first website to track
Returns:
The created Task object with type="PLAYWRIGHT"
"""
payload = build_umami_initial_setup_step(
base_url=base_url,
admin_password=admin_password,
website_name=website_name,
website_url=website_url,
)
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

View File

@@ -0,0 +1,194 @@
"""Uptime Kuma monitoring deployment playbook.
Defines the steps required to:
1. Set up Uptime Kuma on a tenant server (ENV_UPDATE + DOCKER_RELOAD)
2. Perform initial setup via Playwright automation (create admin account)
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
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
UPTIME_KUMA_ENV_PATH = "/opt/letsbe/env/uptime-kuma.env"
UPTIME_KUMA_STACK_DIR = "/opt/letsbe/stacks/uptime-kuma"
def build_uptime_kuma_setup_steps(*, domain: str) -> list[CompositeStep]:
"""
Build the sequence of steps required to set up Uptime Kuma.
Assumes the env file already exists at /opt/letsbe/env/uptime-kuma.env
(created by provisioning/env_setup.sh).
Args:
domain: The domain for Uptime Kuma (e.g., "status.example.com")
Returns:
List of 2 CompositeStep objects:
1. ENV_UPDATE - patches UPTIME_KUMA_DOMAIN
2. DOCKER_RELOAD - restarts the uptime-kuma stack with pull=True
"""
steps = [
# Step 1: Update environment variables
CompositeStep(
type="ENV_UPDATE",
payload={
"path": UPTIME_KUMA_ENV_PATH,
"updates": {
"UPTIME_KUMA_DOMAIN": domain,
},
},
),
# Step 2: Reload Docker stack
CompositeStep(
type="DOCKER_RELOAD",
payload={
"compose_dir": UPTIME_KUMA_STACK_DIR,
"pull": True,
},
),
]
return steps
async def create_uptime_kuma_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID | None,
domain: str,
) -> Task:
"""
Create and persist a COMPOSITE task for Uptime Kuma setup.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: Optional UUID of the agent to assign the task to
domain: The domain for Uptime Kuma
Returns:
The created Task object with type="COMPOSITE"
"""
steps = build_uptime_kuma_setup_steps(domain=domain)
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
# =============================================================================
# Initial Setup via Playwright
# =============================================================================
def build_uptime_kuma_initial_setup_step(
*,
base_url: str,
admin_username: str = "admin",
admin_password: str | None = None,
) -> dict[str, Any]:
"""
Build a PLAYWRIGHT task payload for Uptime Kuma initial setup.
This creates the admin account on a fresh Uptime Kuma installation.
Args:
base_url: The base URL for Uptime Kuma (e.g., "https://status.example.com")
admin_username: Username for the admin account
admin_password: Password for admin (auto-generated if None)
Returns:
Task payload dict with type="PLAYWRIGHT"
"""
parsed = urlparse(base_url)
allowed_domain = parsed.netloc
inputs: dict[str, Any] = {
"base_url": base_url,
"admin_username": admin_username,
}
if admin_password:
inputs["admin_password"] = admin_password
return {
"scenario": "uptime_kuma_initial_setup",
"inputs": inputs,
"options": {
"allowed_domains": [allowed_domain],
},
"timeout": 120,
}
async def create_uptime_kuma_initial_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID,
base_url: str,
admin_username: str = "admin",
admin_password: str | None = None,
) -> Task:
"""
Create and persist a PLAYWRIGHT task for Uptime Kuma 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 Uptime Kuma
admin_username: Username for the admin account
admin_password: Password for admin (auto-generated if None)
Returns:
The created Task object with type="PLAYWRIGHT"
"""
payload = build_uptime_kuma_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

View File

@@ -0,0 +1,121 @@
"""Vaultwarden password manager deployment playbook.
Defines the steps required to set up Vaultwarden on a tenant server
(ENV_UPDATE + DOCKER_RELOAD). No Playwright setup needed - Vaultwarden
uses a web-based registration flow that doesn't require automation.
Tenant servers must have stacks and env templates under /opt/letsbe.
"""
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
VAULTWARDEN_ENV_PATH = "/opt/letsbe/env/vaultwarden.env"
VAULTWARDEN_STACK_DIR = "/opt/letsbe/stacks/vaultwarden"
def build_vaultwarden_setup_steps(
*,
domain: str,
admin_token: str,
signups_allowed: bool = True,
) -> list[CompositeStep]:
"""
Build the sequence of steps required to set up Vaultwarden.
Assumes the env file already exists at /opt/letsbe/env/vaultwarden.env
(created by provisioning/env_setup.sh).
Args:
domain: The domain for Vaultwarden (e.g., "vault.example.com")
admin_token: Admin panel access token
signups_allowed: Whether new user registration is allowed
Returns:
List of 2 CompositeStep objects:
1. ENV_UPDATE - patches DOMAIN, ADMIN_TOKEN, SIGNUPS_ALLOWED
2. DOCKER_RELOAD - restarts the vaultwarden stack with pull=True
"""
steps = [
# Step 1: Update environment variables
CompositeStep(
type="ENV_UPDATE",
payload={
"path": VAULTWARDEN_ENV_PATH,
"updates": {
"DOMAIN": f"https://{domain}",
"ADMIN_TOKEN": admin_token,
"SIGNUPS_ALLOWED": str(signups_allowed).lower(),
},
},
),
# Step 2: Reload Docker stack
CompositeStep(
type="DOCKER_RELOAD",
payload={
"compose_dir": VAULTWARDEN_STACK_DIR,
"pull": True,
},
),
]
return steps
async def create_vaultwarden_setup_task(
*,
db: AsyncSession,
tenant_id: uuid.UUID,
agent_id: uuid.UUID | None,
domain: str,
admin_token: str,
signups_allowed: bool = True,
) -> Task:
"""
Create and persist a COMPOSITE task for Vaultwarden setup.
Args:
db: Async database session
tenant_id: UUID of the tenant
agent_id: Optional UUID of the agent to assign the task to
domain: The domain for Vaultwarden
admin_token: Admin panel access token
signups_allowed: Whether new user registration is allowed
Returns:
The created Task object with type="COMPOSITE"
"""
steps = build_vaultwarden_setup_steps(
domain=domain,
admin_token=admin_token,
signups_allowed=signups_allowed,
)
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

@@ -0,0 +1,25 @@
"""FastAPI route modules."""
from app.routes.health import router as health_router
from app.routes.tasks import router as tasks_router
from app.routes.tenants import router as tenants_router
from app.routes.agents import router as agents_router
from app.routes.playbooks import router as playbooks_router
from app.routes.env import router as env_router
from app.routes.events import router as events_router
from app.routes.files import router as files_router
from app.routes.registration_tokens import router as registration_tokens_router
from app.routes.meta import router as meta_router
__all__ = [
"health_router",
"tenants_router",
"tasks_router",
"agents_router",
"playbooks_router",
"env_router",
"events_router",
"files_router",
"registration_tokens_router",
"meta_router",
]

View File

@@ -0,0 +1,529 @@
"""Agent management endpoints."""
import hashlib
import logging
import secrets
import uuid
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
from pydantic import ValidationError
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.dependencies.auth import CurrentAgentDep
from app.dependencies.local_agent_auth import verify_local_agent_key
from app.models.agent import Agent, AgentStatus
from app.models.base import utc_now
from app.models.registration_token import RegistrationToken
from app.models.tenant import Tenant
from app.schemas.agent import (
AgentHeartbeatResponse,
AgentRegisterRequest,
AgentRegisterRequestLegacy,
AgentRegisterResponse,
AgentRegisterResponseLegacy,
AgentResponse,
LocalAgentRegisterRequest,
LocalAgentRegisterResponse,
)
from app.services.local_bootstrap import LocalBootstrapService
logger = logging.getLogger(__name__)
limiter = Limiter(key_func=get_remote_address)
router = APIRouter(prefix="/agents", tags=["Agents"])
# --- Helper functions (embryonic service layer) ---
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_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_registration_token_by_hash(
db: AsyncSessionDep, token_hash: str
) -> RegistrationToken | None:
"""Retrieve a registration token by its hash."""
result = await db.execute(
select(RegistrationToken).where(RegistrationToken.token_hash == token_hash)
)
return result.scalar_one_or_none()
async def get_agent_by_tenant(
db: AsyncSessionDep, tenant_id: uuid.UUID
) -> Agent | None:
"""Retrieve the first agent for a tenant (used for local mode single-agent)."""
result = await db.execute(
select(Agent).where(Agent.tenant_id == tenant_id).limit(1)
)
return result.scalar_one_or_none()
async def validate_agent_token(
db: AsyncSessionDep,
agent_id: uuid.UUID,
authorization: str | None,
) -> Agent:
"""
Validate agent exists and token matches (legacy method).
Args:
db: Database session
agent_id: Agent UUID
authorization: Authorization header value
Returns:
Agent if valid
Raises:
HTTPException: 401 if invalid
"""
if authorization is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Authorization header",
)
# Parse Bearer token
parts = authorization.split(" ", 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Authorization header format. Expected: Bearer <token>",
)
token = parts[1]
# Find and validate agent
agent = await get_agent_by_id(db, agent_id)
if agent is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid agent credentials",
)
# Use secrets.compare_digest for timing-attack-safe comparison
if not secrets.compare_digest(agent.token, token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid agent credentials",
)
return agent
# --- Route handlers (thin controllers) ---
@router.get(
"",
response_model=list[AgentResponse],
summary="List all agents",
description="Retrieve all registered agents, optionally filtered by tenant.",
)
async def list_agents(
db: AsyncSessionDep,
tenant_id: uuid.UUID | None = None,
) -> list[Agent]:
"""List all agents, optionally filtered by tenant."""
query = select(Agent)
if tenant_id:
query = query.where(Agent.tenant_id == tenant_id)
query = query.order_by(Agent.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())
@router.get(
"/{agent_id}",
response_model=AgentResponse,
summary="Get agent by ID",
description="Retrieve a specific agent by its UUID.",
)
async def get_agent(
agent_id: uuid.UUID,
db: AsyncSessionDep,
) -> Agent:
"""Get a specific agent by ID."""
agent = await get_agent_by_id(db, agent_id)
if agent is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Agent {agent_id} not found",
)
return agent
@router.post(
"/register",
response_model=AgentRegisterResponse | AgentRegisterResponseLegacy,
status_code=status.HTTP_201_CREATED,
summary="Register a new agent",
description="""
Register a new SysAdmin agent with the orchestrator.
**New Secure Flow (Recommended):**
- Provide `registration_token` obtained from `/api/v1/tenants/{id}/registration-tokens`
- The token determines which tenant the agent belongs to
- Returns `agent_id`, `agent_secret`, and `tenant_id`
- Store `agent_secret` securely - it's only shown once
**Legacy Flow (Deprecated):**
- Provide optional `tenant_id` directly
- Returns `agent_id` and `token`
- This flow will be removed in a future version
""",
)
@limiter.limit("5/minute")
async def register_agent(
request: Request,
body: dict,
db: AsyncSessionDep,
) -> AgentRegisterResponse | AgentRegisterResponseLegacy:
"""
Register a new SysAdmin agent.
Supports both new (registration_token) and legacy (tenant_id) flows.
"""
# Determine which registration flow to use
if "registration_token" in body:
# New secure registration flow
try:
parsed = AgentRegisterRequest.model_validate(body)
except ValidationError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=e.errors(),
)
return await _register_agent_secure(parsed, db)
else:
# Legacy registration flow (deprecated)
logger.warning(
"legacy_registration_used",
extra={"message": "Agent using deprecated registration without token"},
)
try:
parsed = AgentRegisterRequestLegacy.model_validate(body)
except ValidationError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=e.errors(),
)
return await _register_agent_legacy(parsed, db)
async def _register_agent_secure(
request: AgentRegisterRequest,
db: AsyncSessionDep,
) -> AgentRegisterResponse:
"""Register agent using the new secure token-based flow."""
# Hash the provided registration token
token_hash = hashlib.sha256(request.registration_token.encode()).hexdigest()
# Look up the registration token
reg_token = await get_registration_token_by_hash(db, token_hash)
if reg_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid registration token",
)
# Validate token state
if not reg_token.is_valid():
if reg_token.revoked:
detail = "Registration token has been revoked"
elif reg_token.expires_at and reg_token.expires_at < utc_now():
detail = "Registration token has expired"
else:
detail = "Registration token has been exhausted"
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
)
# Increment use count
reg_token.use_count += 1
# Generate agent credentials
agent_id = uuid.uuid4()
agent_secret = secrets.token_hex(32)
secret_hash = hashlib.sha256(agent_secret.encode()).hexdigest()
# Create agent with tenant from token
agent = Agent(
id=agent_id,
name=request.hostname,
version=request.version,
status=AgentStatus.ONLINE.value,
last_heartbeat=utc_now(),
token="", # Legacy field - empty for new agents
secret_hash=secret_hash,
tenant_id=reg_token.tenant_id,
registration_token_id=reg_token.id,
)
db.add(agent)
await db.commit()
logger.info(
"agent_registered",
extra={
"agent_id": str(agent_id),
"tenant_id": str(reg_token.tenant_id),
"hostname": request.hostname,
"registration_token_id": str(reg_token.id),
},
)
return AgentRegisterResponse(
agent_id=agent_id,
agent_secret=agent_secret,
tenant_id=reg_token.tenant_id,
)
async def _register_agent_legacy(
request: AgentRegisterRequestLegacy,
db: AsyncSessionDep,
) -> AgentRegisterResponseLegacy:
"""Register agent using the legacy flow (deprecated)."""
# Validate tenant exists if provided
if request.tenant_id is not None:
tenant = await get_tenant_by_id(db, request.tenant_id)
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {request.tenant_id} not found",
)
agent_id = uuid.uuid4()
token = secrets.token_hex(32)
# For legacy agents, also compute the secret_hash from the token
# This allows them to work with the new auth scheme
secret_hash = hashlib.sha256(token.encode()).hexdigest()
agent = Agent(
id=agent_id,
name=request.hostname,
version=request.version,
status=AgentStatus.ONLINE.value,
last_heartbeat=utc_now(),
token=token, # Legacy field - used for backward compatibility
secret_hash=secret_hash, # Also set for new auth scheme
tenant_id=request.tenant_id,
)
db.add(agent)
await db.commit()
logger.info(
"agent_registered_legacy",
extra={
"agent_id": str(agent_id),
"tenant_id": str(request.tenant_id) if request.tenant_id else None,
"hostname": request.hostname,
},
)
return AgentRegisterResponseLegacy(agent_id=agent_id, token=token)
@router.post(
"/register-local",
response_model=LocalAgentRegisterResponse,
summary="Register agent in LOCAL_MODE",
description="""
Register the local SysAdmin agent in LOCAL_MODE.
**Important:** This endpoint only exists when `LOCAL_MODE=true`.
**Authentication:**
- Requires `X-Local-Agent-Key` header (NOT `X-Admin-Api-Key`)
- LOCAL_AGENT_KEY has minimal scope - can only register the local agent
**Idempotent Behavior:**
- First call: Creates agent, returns `agent_secret` (201 Created)
- Subsequent calls: Returns existing `agent_id`, NO secret (200 OK)
- With `rotate=true`: Deletes existing agent, returns new credentials (201 Created)
**HTTP Status Codes:**
- 201: New agent created (or rotated)
- 200: Existing agent returned (no secret)
- 404: Endpoint hidden (LOCAL_MODE is false)
- 401: Invalid or missing LOCAL_AGENT_KEY
- 503: Local tenant not bootstrapped yet
**Security:**
- LOCAL_AGENT_KEY is separate from ADMIN_API_KEY (principle of least privilege)
- Agent secret is only shown once (on first registration or rotation)
- Rotation is logged as a security event
""",
responses={
201: {"description": "Agent created or rotated"},
200: {"description": "Existing agent returned (no secret)"},
401: {"description": "Invalid LOCAL_AGENT_KEY"},
404: {"description": "Endpoint hidden (LOCAL_MODE=false)"},
503: {"description": "Local tenant not bootstrapped"},
},
)
@limiter.limit("5/minute")
async def register_agent_local(
request: Request,
body: LocalAgentRegisterRequest,
response: Response,
db: AsyncSessionDep,
rotate: bool = Query(
default=False,
description="Force credential rotation (deletes existing agent, creates new one)",
),
_auth: None = Depends(verify_local_agent_key),
) -> LocalAgentRegisterResponse:
"""
Register an agent in LOCAL_MODE using LOCAL_AGENT_KEY.
This endpoint:
- Only works when LOCAL_MODE=true
- Requires valid LOCAL_AGENT_KEY (NOT ADMIN_API_KEY)
- Creates agent for the auto-bootstrapped local tenant
- Idempotent: if agent exists, returns existing agent_id (no new secret)
- With rotate=true: deletes existing, creates new with fresh credentials
Security: LOCAL_AGENT_KEY has minimal scope - can only register
the local agent, nothing else.
"""
# Get local tenant ID from bootstrap service
local_tenant_id = LocalBootstrapService.get_local_tenant_id()
if local_tenant_id is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Local tenant not bootstrapped. Orchestrator is starting up.",
headers={"Retry-After": "5"},
)
# Check if agent already exists for this tenant
existing_agent = await get_agent_by_tenant(db, local_tenant_id)
# Handle rotation request
if rotate and existing_agent:
logger.warning(
"local_agent_credentials_rotated",
extra={
"agent_id": str(existing_agent.id),
"tenant_id": str(local_tenant_id),
"hostname": existing_agent.name,
"new_hostname": body.hostname,
},
)
await db.delete(existing_agent)
await db.commit()
existing_agent = None # Proceed to create new agent
# Idempotent: return existing agent without secret
if existing_agent:
logger.info(
"local_agent_already_registered",
extra={
"agent_id": str(existing_agent.id),
"tenant_id": str(local_tenant_id),
"hostname": existing_agent.name,
},
)
response.status_code = status.HTTP_200_OK
return LocalAgentRegisterResponse(
agent_id=existing_agent.id,
tenant_id=local_tenant_id,
agent_secret=None,
already_registered=True,
)
# Create new agent
agent_id = uuid.uuid4()
agent_secret = secrets.token_hex(32)
secret_hash = hashlib.sha256(agent_secret.encode()).hexdigest()
agent = Agent(
id=agent_id,
name=body.hostname,
version=body.version,
status=AgentStatus.ONLINE.value,
last_heartbeat=utc_now(),
token="", # Legacy field - empty for new agents
secret_hash=secret_hash,
tenant_id=local_tenant_id,
registration_token_id=None, # No registration token in local mode
)
db.add(agent)
await db.commit()
logger.info(
"local_agent_registered",
extra={
"agent_id": str(agent_id),
"tenant_id": str(local_tenant_id),
"hostname": body.hostname,
"rotated": rotate,
},
)
response.status_code = status.HTTP_201_CREATED
return LocalAgentRegisterResponse(
agent_id=agent_id,
tenant_id=local_tenant_id,
agent_secret=agent_secret,
already_registered=False,
)
@router.post(
"/{agent_id}/heartbeat",
response_model=AgentHeartbeatResponse,
summary="Send agent heartbeat",
description="""
Send a heartbeat from an agent.
Updates the agent's last_heartbeat timestamp and sets status to online.
**Authentication:**
- New: X-Agent-Id and X-Agent-Secret headers
- Legacy: Authorization: Bearer <token> header
""",
)
async def agent_heartbeat(
agent_id: uuid.UUID,
db: AsyncSessionDep,
current_agent: CurrentAgentDep,
) -> AgentHeartbeatResponse:
"""
Send heartbeat from agent.
Updates last_heartbeat timestamp and sets status to online.
"""
# Verify the path agent_id matches the authenticated agent
if agent_id != current_agent.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Agent ID mismatch",
)
# Update heartbeat
current_agent.last_heartbeat = utc_now()
current_agent.status = AgentStatus.ONLINE.value
await db.commit()
return AgentHeartbeatResponse(status="ok")

View File

@@ -0,0 +1,158 @@
"""Env management endpoints for creating ENV_INSPECT and ENV_UPDATE tasks."""
import uuid
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.models.agent import Agent
from app.models.task import Task, TaskStatus
from app.models.tenant import Tenant
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
from app.schemas.task import TaskResponse
router = APIRouter(prefix="/agents/{agent_id}/env", tags=["Env Management"])
# --- 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()
# --- Route handlers ---
@router.post(
"/inspect",
response_model=TaskResponse,
status_code=status.HTTP_201_CREATED,
)
async def inspect_env(
agent_id: uuid.UUID,
request: EnvInspectRequest,
db: AsyncSessionDep,
) -> Task:
"""
Create an ENV_INSPECT task to read env file contents.
The SysAdmin Agent will execute this task and return the env file
key-value pairs in the task result.
## Request Body
- **tenant_id**: UUID of the tenant
- **path**: Path to the env file (e.g., `/opt/letsbe/env/chatwoot.env`)
- **keys**: Optional list of specific keys to inspect (returns all if omitted)
## Response
Returns the created Task with type="ENV_INSPECT" and status="pending".
"""
# Validate tenant exists
tenant = await get_tenant_by_id(db, request.tenant_id)
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {request.tenant_id} not found",
)
# Validate agent exists
agent = await get_agent_by_id(db, agent_id)
if agent is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Agent {agent_id} not found",
)
# Build payload
payload: dict = {"path": request.path}
if request.keys is not None:
payload["keys"] = request.keys
# Create the task
task = Task(
tenant_id=request.tenant_id,
agent_id=agent_id,
type="ENV_INSPECT",
payload=payload,
status=TaskStatus.PENDING.value,
)
db.add(task)
await db.commit()
await db.refresh(task)
return task
@router.post(
"/update",
response_model=TaskResponse,
status_code=status.HTTP_201_CREATED,
)
async def update_env(
agent_id: uuid.UUID,
request: EnvUpdateRequest,
db: AsyncSessionDep,
) -> Task:
"""
Create an ENV_UPDATE task to modify env file contents.
The SysAdmin Agent will execute this task to update or remove
key-value pairs in the specified env file.
## Request Body
- **tenant_id**: UUID of the tenant
- **path**: Path to the env file (e.g., `/opt/letsbe/env/chatwoot.env`)
- **updates**: Optional dict of key-value pairs to set or update
- **remove_keys**: Optional list of keys to remove from the env file
## Response
Returns the created Task with type="ENV_UPDATE" and status="pending".
"""
# Validate tenant exists
tenant = await get_tenant_by_id(db, request.tenant_id)
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {request.tenant_id} not found",
)
# Validate agent exists
agent = await get_agent_by_id(db, agent_id)
if agent is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Agent {agent_id} not found",
)
# Build payload
payload: dict = {"path": request.path}
if request.updates is not None:
payload["updates"] = request.updates
if request.remove_keys is not None:
payload["remove_keys"] = request.remove_keys
# Create the task
task = Task(
tenant_id=request.tenant_id,
agent_id=agent_id,
type="ENV_UPDATE",
payload=payload,
status=TaskStatus.PENDING.value,
)
db.add(task)
await db.commit()
await db.refresh(task)
return task

View File

@@ -0,0 +1,77 @@
"""Event management endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.dependencies.admin_auth import verify_admin_api_key
from app.models.event import Event
from app.schemas.event import EventCreate, EventResponse
router = APIRouter(prefix="/events", tags=["Events"])
@router.get("", response_model=list[EventResponse])
async def list_events(
db: AsyncSessionDep,
event_type: str | None = Query(None, description="Filter by event type"),
tenant_id: uuid.UUID | None = Query(None, description="Filter by tenant ID"),
limit: int = Query(50, ge=1, le=200, description="Maximum number of events to return"),
offset: int = Query(0, ge=0, description="Number of events to skip"),
) -> list[Event]:
"""List events with optional filtering and pagination."""
query = select(Event).order_by(Event.created_at.desc())
if event_type is not None:
query = query.where(Event.event_type == event_type)
if tenant_id is not None:
query = query.where(Event.tenant_id == tenant_id)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return list(result.scalars().all())
@router.get("/{event_id}", response_model=EventResponse)
async def get_event(
event_id: uuid.UUID,
db: AsyncSessionDep,
) -> Event:
"""Get an event by ID."""
result = await db.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if event is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Event {event_id} not found",
)
return event
@router.post(
"",
response_model=EventResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(verify_admin_api_key)],
)
async def create_event(
event_in: EventCreate,
db: AsyncSessionDep,
) -> Event:
"""Create a new event (admin auth required)."""
event = Event(
tenant_id=event_in.tenant_id,
task_id=event_in.task_id,
event_type=event_in.event_type,
payload=event_in.payload,
)
db.add(event)
await db.commit()
await db.refresh(event)
return event

View File

@@ -0,0 +1,94 @@
"""File management endpoints for creating FILE_INSPECT tasks."""
import uuid
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.models.agent import Agent
from app.models.task import Task, TaskStatus
from app.models.tenant import Tenant
from app.schemas.file import FileInspectRequest
from app.schemas.task import TaskResponse
router = APIRouter(prefix="/agents/{agent_id}/files", tags=["File Management"])
# --- 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()
# --- Route handlers ---
@router.post(
"/inspect",
response_model=TaskResponse,
status_code=status.HTTP_201_CREATED,
)
async def inspect_file(
agent_id: uuid.UUID,
request: FileInspectRequest,
db: AsyncSessionDep,
) -> Task:
"""
Create a FILE_INSPECT task to read file contents.
The SysAdmin Agent will execute this task and return the file
contents (up to max_bytes) in the task result.
## Request Body
- **tenant_id**: UUID of the tenant
- **path**: Absolute path to the file to inspect
- **max_bytes**: Optional max bytes to read (default 4096, max 1MB)
## Response
Returns the created Task with type="FILE_INSPECT" and status="pending".
"""
# Validate tenant exists
tenant = await get_tenant_by_id(db, request.tenant_id)
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {request.tenant_id} not found",
)
# Validate agent exists
agent = await get_agent_by_id(db, agent_id)
if agent is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Agent {agent_id} not found",
)
# Build payload
payload: dict = {"path": request.path}
if request.max_bytes is not None:
payload["max_bytes"] = request.max_bytes
# Create the task
task = Task(
tenant_id=request.tenant_id,
agent_id=agent_id,
type="FILE_INSPECT",
payload=payload,
status=TaskStatus.PENDING.value,
)
db.add(task)
await db.commit()
await db.refresh(task)
return task

View File

@@ -0,0 +1,21 @@
"""Health check endpoints."""
from fastapi import APIRouter
from app.config import settings
from app.schemas.common import HealthResponse
router = APIRouter(tags=["Health"])
@router.get("/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
"""
Health check endpoint.
Returns the current status and version of the API.
"""
return HealthResponse(
status="ok",
version=settings.APP_VERSION,
)

View File

@@ -0,0 +1,35 @@
"""Meta/instance endpoints for diagnostics and identification."""
from fastapi import APIRouter
from app.config import settings
from app.schemas.common import InstanceMetaResponse
from app.services.local_bootstrap import LocalBootstrapService
router = APIRouter(prefix="/meta", tags=["Meta"])
@router.get("/instance", response_model=InstanceMetaResponse)
async def get_instance_meta() -> InstanceMetaResponse:
"""
Get instance metadata.
This endpoint is stable and works even before tenant bootstrap completes.
Use it for diagnostics, health checks, and instance identification.
Returns:
- instance_id: Unique instance identifier (from Hub activation)
- local_mode: Whether running in single-tenant local mode
- version: Application version
- tenant_id: Local tenant ID (null if not bootstrapped or in multi-tenant mode)
- bootstrap_status: Detailed bootstrap status for debugging
"""
tenant_id = LocalBootstrapService.get_local_tenant_id()
return InstanceMetaResponse(
instance_id=settings.INSTANCE_ID,
local_mode=settings.LOCAL_MODE,
version=settings.APP_VERSION,
tenant_id=str(tenant_id) if tenant_id else None,
bootstrap_status=LocalBootstrapService.get_bootstrap_status(),
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
"""Registration token management endpoints."""
import hashlib
import uuid
from datetime import timedelta
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.dependencies.admin_auth import AdminAuthDep
from app.models.base import utc_now
from app.models.registration_token import RegistrationToken
from app.models.tenant import Tenant
from app.schemas.registration_token import (
RegistrationTokenCreate,
RegistrationTokenCreatedResponse,
RegistrationTokenList,
RegistrationTokenResponse,
)
router = APIRouter(
prefix="/tenants/{tenant_id}/registration-tokens",
tags=["Registration Tokens"],
dependencies=[AdminAuthDep],
)
# --- 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_token_by_id(
db: AsyncSessionDep, tenant_id: uuid.UUID, token_id: uuid.UUID
) -> RegistrationToken | None:
"""Retrieve a registration token by ID, scoped to tenant."""
result = await db.execute(
select(RegistrationToken).where(
RegistrationToken.id == token_id,
RegistrationToken.tenant_id == tenant_id,
)
)
return result.scalar_one_or_none()
# --- Route handlers ---
@router.post(
"",
response_model=RegistrationTokenCreatedResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a registration token",
description="""
Create a new registration token for a tenant.
The token can be used by agents to register with the orchestrator.
The plaintext token is only returned once - store it securely.
**Authentication:** Requires X-Admin-Api-Key header.
""",
)
async def create_registration_token(
tenant_id: uuid.UUID,
request: RegistrationTokenCreate,
db: AsyncSessionDep,
) -> RegistrationTokenCreatedResponse:
"""Create a new registration token for a tenant."""
# Verify 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",
)
# Generate token (UUID format for uniqueness)
plaintext_token = str(uuid.uuid4())
token_hash = hashlib.sha256(plaintext_token.encode()).hexdigest()
# Calculate expiration if specified
expires_at = None
if request.expires_in_hours is not None:
expires_at = utc_now() + timedelta(hours=request.expires_in_hours)
# Create token record
token_record = RegistrationToken(
tenant_id=tenant_id,
token_hash=token_hash,
description=request.description,
max_uses=request.max_uses,
expires_at=expires_at,
)
db.add(token_record)
await db.commit()
await db.refresh(token_record)
# Return response with plaintext token (only time it's shown)
return RegistrationTokenCreatedResponse(
id=token_record.id,
tenant_id=token_record.tenant_id,
description=token_record.description,
max_uses=token_record.max_uses,
use_count=token_record.use_count,
expires_at=token_record.expires_at,
revoked=token_record.revoked,
created_at=token_record.created_at,
created_by=token_record.created_by,
token=plaintext_token,
)
@router.get(
"",
response_model=RegistrationTokenList,
summary="List registration tokens",
description="""
List all registration tokens for a tenant.
Note: The plaintext token values are not returned.
**Authentication:** Requires X-Admin-Api-Key header.
""",
)
async def list_registration_tokens(
tenant_id: uuid.UUID,
db: AsyncSessionDep,
) -> RegistrationTokenList:
"""List all registration tokens for a tenant."""
# Verify 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",
)
# Get all tokens for tenant
result = await db.execute(
select(RegistrationToken)
.where(RegistrationToken.tenant_id == tenant_id)
.order_by(RegistrationToken.created_at.desc())
)
tokens = result.scalars().all()
return RegistrationTokenList(
tokens=[RegistrationTokenResponse.model_validate(t) for t in tokens],
total=len(tokens),
)
@router.get(
"/{token_id}",
response_model=RegistrationTokenResponse,
summary="Get registration token details",
description="""
Get details of a specific registration token.
Note: The plaintext token value is not returned.
**Authentication:** Requires X-Admin-Api-Key header.
""",
)
async def get_registration_token(
tenant_id: uuid.UUID,
token_id: uuid.UUID,
db: AsyncSessionDep,
) -> RegistrationTokenResponse:
"""Get details of a specific registration token."""
token = await get_token_by_id(db, tenant_id, token_id)
if token is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Registration token {token_id} not found",
)
return RegistrationTokenResponse.model_validate(token)
@router.delete(
"/{token_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Revoke registration token",
description="""
Revoke a registration token.
Revoked tokens cannot be used for new agent registrations.
Agents that have already registered with this token will continue to work.
**Authentication:** Requires X-Admin-Api-Key header.
""",
)
async def revoke_registration_token(
tenant_id: uuid.UUID,
token_id: uuid.UUID,
db: AsyncSessionDep,
) -> None:
"""Revoke a registration token."""
token = await get_token_by_id(db, tenant_id, token_id)
if token is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Registration token {token_id} not found",
)
# Mark as revoked
token.revoked = True
await db.commit()

View File

@@ -0,0 +1,283 @@
"""Task management endpoints."""
import uuid
from fastapi import APIRouter, HTTPException, Query, status
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.dependencies.auth import CurrentAgentDep
from app.models.agent import Agent
from app.models.task import Task, TaskStatus
from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate
router = APIRouter(prefix="/tasks", tags=["Tasks"])
# --- Helper functions (embryonic service layer) ---
async def create_task(db: AsyncSessionDep, task_in: TaskCreate) -> Task:
"""Create a new task in the database."""
task = Task(
tenant_id=task_in.tenant_id,
type=task_in.type,
payload=task_in.payload,
status=TaskStatus.PENDING.value,
)
db.add(task)
await db.commit()
await db.refresh(task)
return task
async def get_tasks(
db: AsyncSessionDep,
tenant_id: uuid.UUID | None = None,
task_status: TaskStatus | None = None,
) -> list[Task]:
"""Retrieve tasks with optional filtering."""
query = select(Task).order_by(Task.created_at.desc())
if tenant_id is not None:
query = query.where(Task.tenant_id == tenant_id)
if task_status is not None:
query = query.where(Task.status == task_status.value)
result = await db.execute(query)
return list(result.scalars().all())
async def get_task_by_id(db: AsyncSessionDep, task_id: uuid.UUID) -> Task | None:
"""Retrieve a task by ID."""
result = await db.execute(select(Task).where(Task.id == task_id))
return result.scalar_one_or_none()
async def update_task(
db: AsyncSessionDep,
task: Task,
task_update: TaskUpdate,
) -> Task:
"""Update a task's status and/or result."""
if task_update.status is not None:
task.status = task_update.status.value
if task_update.result is not None:
task.result = task_update.result
await db.commit()
await db.refresh(task)
return task
# --- Route handlers (thin controllers) ---
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task_endpoint(
task_in: TaskCreate,
db: AsyncSessionDep,
) -> Task:
"""
Create a new task for agent execution.
## Parameters
- **tenant_id**: UUID of the tenant this task belongs to
- **type**: Task type string (see supported types below)
- **payload**: JSON payload with task-specific parameters
## Supported Task Types
| Type | Description | Payload |
|------|-------------|---------|
| FILE_WRITE | Write content to a file | `{"path": str, "content": str}` |
| ENV_UPDATE | Update .env key/value pairs | `{"path": str, "updates": {str: str}}` |
| DOCKER_RELOAD | Reload Docker Compose stack | `{"compose_dir": str}` |
| COMPOSITE | Execute sequence of sub-tasks | `{"sequence": [{task, payload}, ...]}` |
## Agent Behavior
1. Agent polls `GET /tasks/next` to claim pending tasks
2. Agent executes the task based on type and payload
3. Agent updates task status via `PATCH /tasks/{id}`
## Example Payloads
**FILE_WRITE:**
```json
{"path": "/opt/app/config.json", "content": "{\"key\": \"value\"}"}
```
**ENV_UPDATE:**
```json
{"path": "/opt/app/.env", "updates": {"DB_HOST": "localhost", "DB_PORT": "5432"}}
```
**DOCKER_RELOAD:**
```json
{"compose_dir": "/opt/stacks/keycloak"}
```
**COMPOSITE:**
```json
{
"sequence": [
{"task": "FILE_WRITE", "payload": {"path": "/opt/app/config.json", "content": "{}"}},
{"task": "DOCKER_RELOAD", "payload": {"compose_dir": "/opt/stacks/app"}}
]
}
```
"""
return await create_task(db, task_in)
@router.get("", response_model=list[TaskResponse])
async def list_tasks_endpoint(
db: AsyncSessionDep,
tenant_id: uuid.UUID | None = Query(None, description="Filter by tenant ID"),
status: TaskStatus | None = Query(None, description="Filter by task status"),
) -> list[Task]:
"""
List all tasks with optional filtering.
## Query Parameters
- **tenant_id**: Optional filter by tenant UUID
- **status**: Optional filter by task status (pending, running, completed, failed)
## Task Types
Tasks may have the following types:
- **FILE_WRITE**: Write content to a file
- **ENV_UPDATE**: Update .env key/value pairs
- **DOCKER_RELOAD**: Reload Docker Compose stack
- **COMPOSITE**: Execute sequence of sub-tasks
- Legacy types: provision_server, configure_keycloak, etc.
## Response
Returns tasks ordered by created_at descending (newest first).
Each task includes: id, tenant_id, agent_id, type, payload, status, result, timestamps.
"""
return await get_tasks(db, tenant_id=tenant_id, task_status=status)
# --- Agent task acquisition ---
# NOTE: /next must be defined BEFORE /{task_id} to avoid path matching issues
async def get_next_pending_task(db: AsyncSessionDep, agent: Agent) -> Task | None:
"""Get the oldest pending task for the agent's tenant.
If the agent has a tenant_id, only returns tasks for that tenant.
If the agent has no tenant_id (shared agent), returns any pending task.
"""
query = select(Task).where(Task.status == TaskStatus.PENDING.value)
# Filter by agent's tenant if agent is tenant-specific
if agent.tenant_id is not None:
query = query.where(Task.tenant_id == agent.tenant_id)
query = query.order_by(Task.created_at.asc()).limit(1)
result = await db.execute(query)
return result.scalar_one_or_none()
@router.get("/next", response_model=TaskResponse | None)
async def get_next_task_endpoint(
db: AsyncSessionDep,
current_agent: CurrentAgentDep,
) -> Task | None:
"""
Get the next pending task for an agent.
**Authentication:**
- New: X-Agent-Id and X-Agent-Secret headers
- Legacy: Authorization: Bearer <token> header
Atomically claims the oldest pending task by:
- Setting status to 'running'
- Assigning agent_id to the requesting agent
Tasks are filtered by the agent's tenant_id:
- If agent has a tenant_id, only returns tasks for that tenant
- If agent has no tenant_id (shared agent), can claim any task
Returns null (200) if no pending tasks are available.
"""
# Get next pending task for this agent's tenant
task = await get_next_pending_task(db, current_agent)
if task is None:
return None
# Claim the task
task.status = TaskStatus.RUNNING.value
task.agent_id = current_agent.id
await db.commit()
await db.refresh(task)
return task
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task_endpoint(
task_id: uuid.UUID,
db: AsyncSessionDep,
) -> Task:
"""
Get a task by ID.
Returns the task with the specified UUID.
"""
task = await get_task_by_id(db, task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task {task_id} not found",
)
return task
@router.patch("/{task_id}", response_model=TaskResponse)
async def update_task_endpoint(
task_id: uuid.UUID,
task_update: TaskUpdate,
db: AsyncSessionDep,
current_agent: CurrentAgentDep,
) -> Task:
"""
Update a task's status and/or result.
**Authentication:**
- New: X-Agent-Id and X-Agent-Secret headers
- Legacy: Authorization: Bearer <token> header
**Authorization:**
- Task must belong to the agent's tenant
- Task must be assigned to the requesting agent
Only status and result fields can be updated.
- **status**: New task status
- **result**: JSON result payload
"""
task = await get_task_by_id(db, task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task {task_id} not found",
)
# Verify tenant ownership (if agent has a tenant_id)
if current_agent.tenant_id is not None and task.tenant_id != current_agent.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Task does not belong to this tenant",
)
# Verify task is assigned to this agent
if task.agent_id != current_agent.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Task is not assigned to this agent",
)
return await update_task(db, task, task_update)

View File

@@ -0,0 +1,185 @@
"""Tenant management endpoints."""
import hashlib
import secrets
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.dependencies import AdminAuthDep
from app.models.tenant import Tenant
from app.schemas.tenant import TenantCreate, TenantResponse
class SetDashboardTokenRequest(BaseModel):
"""Request body for setting dashboard token."""
token: str | None = Field(
None,
min_length=32,
max_length=128,
description="Dashboard token (32-128 chars). If None, generates a new token.",
)
class SetDashboardTokenResponse(BaseModel):
"""Response after setting dashboard token."""
token: str = Field(..., description="The dashboard token (only shown once)")
message: str = Field(default="Dashboard token configured successfully")
router = APIRouter(prefix="/tenants", tags=["Tenants"])
# --- Helper functions (embryonic service layer) ---
async def create_tenant(db: AsyncSessionDep, tenant_in: TenantCreate) -> Tenant:
"""Create a new tenant in the database."""
tenant = Tenant(
name=tenant_in.name,
domain=tenant_in.domain,
)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return tenant
async def get_tenants(db: AsyncSessionDep) -> list[Tenant]:
"""Retrieve all tenants from the database."""
result = await db.execute(select(Tenant).order_by(Tenant.created_at.desc()))
return list(result.scalars().all())
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()
# --- Route handlers (thin controllers) ---
@router.post("", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
async def create_tenant_endpoint(
tenant_in: TenantCreate,
db: AsyncSessionDep,
) -> Tenant:
"""
Create a new tenant.
- **name**: Unique tenant name (required)
- **domain**: Optional domain for the tenant
"""
return await create_tenant(db, tenant_in)
@router.get("", response_model=list[TenantResponse])
async def list_tenants_endpoint(db: AsyncSessionDep) -> list[Tenant]:
"""
List all tenants.
Returns a list of all registered tenants.
"""
return await get_tenants(db)
@router.get("/{tenant_id}", response_model=TenantResponse)
async def get_tenant_endpoint(
tenant_id: uuid.UUID,
db: AsyncSessionDep,
) -> Tenant:
"""
Get a tenant by ID.
Returns the tenant with the specified UUID.
"""
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",
)
return tenant
@router.post(
"/{tenant_id}/dashboard-token",
response_model=SetDashboardTokenResponse,
dependencies=[AdminAuthDep],
)
async def set_dashboard_token_endpoint(
tenant_id: uuid.UUID,
db: AsyncSessionDep,
request: SetDashboardTokenRequest | None = None,
) -> SetDashboardTokenResponse:
"""
Set or regenerate dashboard token for a tenant.
**Admin-only endpoint** - requires X-Admin-Api-Key header.
This token is used by the tenant's dashboard (Hub Dashboard or Control Panel)
to authenticate requests to the Orchestrator.
- If `token` is provided, it will be used (must be 32-128 characters)
- If `token` is None or not provided, a secure 48-character token is generated
**IMPORTANT**: The plaintext token is only returned once. Store it securely.
"""
# Get tenant
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",
)
# Generate or use provided token
if request and request.token:
token = request.token
else:
# Generate secure random token (48 chars = 192 bits of entropy)
token = secrets.token_hex(24)
# Store SHA-256 hash of token
token_hash = hashlib.sha256(token.encode()).hexdigest()
tenant.dashboard_token_hash = token_hash
await db.commit()
return SetDashboardTokenResponse(
token=token,
message="Dashboard token configured successfully. Store this token securely - it will not be shown again.",
)
@router.delete(
"/{tenant_id}/dashboard-token",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[AdminAuthDep],
)
async def revoke_dashboard_token_endpoint(
tenant_id: uuid.UUID,
db: AsyncSessionDep,
) -> None:
"""
Revoke/remove dashboard token for a tenant.
**Admin-only endpoint** - requires X-Admin-Api-Key header.
After revocation, the tenant's dashboard will no longer be able to
authenticate with the Orchestrator until a new token is set.
"""
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",
)
tenant.dashboard_token_hash = None
await db.commit()

View File

@@ -0,0 +1,60 @@
"""Pydantic schemas for API request/response validation."""
from app.schemas.common import HealthResponse, InstanceMetaResponse
from app.schemas.tenant import TenantCreate, TenantResponse
from app.schemas.task import (
TaskCreate,
TaskResponse,
TaskUpdate,
)
from app.schemas.agent import (
AgentRegisterRequest,
AgentRegisterResponse,
AgentHeartbeatResponse,
AgentResponse,
LocalAgentRegisterRequest,
LocalAgentRegisterResponse,
)
from app.schemas.tasks_extended import (
FileWritePayload,
EnvUpdatePayload,
DockerReloadPayload,
CompositeSubTask,
CompositePayload,
)
from app.schemas.env import (
EnvInspectRequest,
EnvUpdateRequest,
)
from app.schemas.file import FileInspectRequest
__all__ = [
# Common
"HealthResponse",
"InstanceMetaResponse",
# Tenant
"TenantCreate",
"TenantResponse",
# Task
"TaskCreate",
"TaskResponse",
"TaskUpdate",
# Task Payloads (for documentation/reference)
"FileWritePayload",
"EnvUpdatePayload",
"DockerReloadPayload",
"CompositeSubTask",
"CompositePayload",
# Agent
"AgentRegisterRequest",
"AgentRegisterResponse",
"AgentHeartbeatResponse",
"AgentResponse",
"LocalAgentRegisterRequest",
"LocalAgentRegisterResponse",
# Env Management
"EnvInspectRequest",
"EnvUpdateRequest",
# File Management
"FileInspectRequest",
]

View File

@@ -0,0 +1,111 @@
"""Agent schemas for API validation."""
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class AgentRegisterRequest(BaseModel):
"""Schema for agent registration request (new secure flow)."""
hostname: str = Field(..., min_length=1, max_length=255)
version: str = Field(..., min_length=1, max_length=50)
metadata: dict[str, Any] | None = None
registration_token: str = Field(
...,
min_length=1,
description="Registration token issued by the orchestrator",
)
class AgentRegisterRequestLegacy(BaseModel):
"""Schema for legacy agent registration request (deprecated).
This schema is kept for backward compatibility during migration.
New agents should use AgentRegisterRequest with registration_token.
"""
hostname: str = Field(..., min_length=1, max_length=255)
version: str = Field(..., min_length=1, max_length=50)
metadata: dict[str, Any] | None = None
tenant_id: uuid.UUID | None = Field(
default=None,
description="Tenant UUID to associate the agent with (DEPRECATED)",
)
class AgentRegisterResponse(BaseModel):
"""Schema for agent registration response."""
agent_id: uuid.UUID
agent_secret: str = Field(
...,
description="Agent secret for authentication. Store securely - shown only once.",
)
tenant_id: uuid.UUID = Field(
...,
description="Tenant this agent is associated with",
)
class LocalAgentRegisterRequest(BaseModel):
"""Schema for LOCAL_MODE agent registration request.
Unlike AgentRegisterRequest, this does NOT include registration_token
because LOCAL_MODE uses X-Local-Agent-Key header authentication.
"""
hostname: str = Field(..., min_length=1, max_length=255)
version: str = Field(..., min_length=1, max_length=50)
metadata: dict[str, Any] | None = None
class LocalAgentRegisterResponse(BaseModel):
"""Schema for LOCAL_MODE agent registration response (idempotent).
This endpoint is idempotent:
- First registration: returns agent_id, agent_secret, already_registered=False
- Subsequent calls: returns agent_id, NO secret, already_registered=True
- With rotate=True: deletes existing, returns new credentials
"""
agent_id: uuid.UUID
tenant_id: uuid.UUID
agent_secret: str | None = Field(
default=None,
description="Agent secret. Only returned on first registration or rotation.",
)
already_registered: bool = Field(
default=False,
description="True if returning existing agent (no new secret).",
)
class AgentRegisterResponseLegacy(BaseModel):
"""Schema for legacy agent registration response (deprecated)."""
agent_id: uuid.UUID
token: str
class AgentHeartbeatResponse(BaseModel):
"""Schema for agent heartbeat response."""
status: str = "ok"
class AgentResponse(BaseModel):
"""Schema for agent response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tenant_id: uuid.UUID | None
name: str
version: str
status: str
last_heartbeat: datetime | None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,39 @@
"""Common schemas used across the API."""
from typing import Generic, Optional, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class HealthResponse(BaseModel):
"""Health check response schema."""
status: str
version: str
class InstanceMetaResponse(BaseModel):
"""
Instance metadata response.
This endpoint is stable even before tenant bootstrap completes.
Used for diagnostics and instance identification.
"""
instance_id: Optional[str] = None
local_mode: bool
version: str
tenant_id: Optional[str] = None
bootstrap_status: dict
class PaginatedResponse(BaseModel, Generic[T]):
"""Generic paginated response wrapper."""
items: list[T]
total: int
page: int
page_size: int
total_pages: int

View File

@@ -0,0 +1,32 @@
"""Pydantic schemas for env management endpoints."""
import uuid
from pydantic import BaseModel, Field
class EnvInspectRequest(BaseModel):
"""Request body for env inspect endpoint."""
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
path: str = Field(
..., min_length=1, description="Path to .env file (e.g., /opt/letsbe/env/chatwoot.env)"
)
keys: list[str] | None = Field(
None, description="Optional list of specific keys to inspect"
)
class EnvUpdateRequest(BaseModel):
"""Request body for env update endpoint."""
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
path: str = Field(
..., min_length=1, description="Path to .env file (e.g., /opt/letsbe/env/chatwoot.env)"
)
updates: dict[str, str] | None = Field(
None, description="Key-value pairs to set or update"
)
remove_keys: list[str] | None = Field(
None, description="Keys to remove from the env file"
)

View File

@@ -0,0 +1,37 @@
"""Event schemas for API validation."""
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class EventCreate(BaseModel):
"""Schema for creating a new event."""
tenant_id: uuid.UUID
task_id: uuid.UUID | None = None
event_type: str = Field(
...,
min_length=1,
max_length=100,
description="Event type identifier (e.g. agent.registered, task.completed)",
)
payload: dict[str, Any] = Field(
default_factory=dict,
description="Event-specific payload data",
)
class EventResponse(BaseModel):
"""Schema for event response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tenant_id: uuid.UUID
task_id: uuid.UUID | None
event_type: str
payload: dict[str, Any]
created_at: datetime

View File

@@ -0,0 +1,20 @@
"""Pydantic schemas for file management endpoints."""
import uuid
from pydantic import BaseModel, Field
class FileInspectRequest(BaseModel):
"""Request body for FILE_INSPECT tasks."""
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
path: str = Field(
..., min_length=1, description="Absolute path to the file to inspect"
)
max_bytes: int | None = Field(
4096,
ge=1,
le=1048576,
description="Max bytes to read from file (default 4096, max 1MB)",
)

View File

@@ -0,0 +1,63 @@
"""Registration token schemas for API validation."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class RegistrationTokenCreate(BaseModel):
"""Schema for creating a new registration token."""
description: str | None = Field(
default=None,
max_length=255,
description="Human-readable description for this token",
)
max_uses: int = Field(
default=1,
ge=0,
description="Maximum number of times this token can be used (0 = unlimited)",
)
expires_in_hours: int | None = Field(
default=None,
ge=1,
le=8760, # Max 1 year
description="Number of hours until this token expires (optional)",
)
class RegistrationTokenResponse(BaseModel):
"""Schema for registration token response (without plaintext token)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tenant_id: uuid.UUID
description: str | None
max_uses: int
use_count: int
expires_at: datetime | None
revoked: bool
created_at: datetime
created_by: str | None
class RegistrationTokenCreatedResponse(RegistrationTokenResponse):
"""Schema for registration token creation response.
This is the only time the plaintext token is returned to the client.
It must be securely stored as it cannot be retrieved again.
"""
token: str = Field(
...,
description="The plaintext registration token. Store this securely - it cannot be retrieved again.",
)
class RegistrationTokenList(BaseModel):
"""Schema for listing registration tokens."""
tokens: list[RegistrationTokenResponse]
total: int

View File

@@ -0,0 +1,70 @@
"""Task schemas for API validation."""
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.models.task import TaskStatus
class TaskCreate(BaseModel):
"""
Schema for creating a new task.
Supported task types and their expected payloads:
**FILE_WRITE** - Write content to a file
payload: {"path": "/absolute/path", "content": "file content"}
**ENV_UPDATE** - Update key/value pairs in a .env file
payload: {"path": "/path/to/.env", "updates": {"KEY": "value"}}
**DOCKER_RELOAD** - Reload a Docker Compose stack
payload: {"compose_dir": "/path/to/compose/dir"}
**COMPOSITE** - Execute a sequence of sub-tasks
payload: {"sequence": [{"task": "FILE_WRITE", "payload": {...}}, ...]}
Legacy types (still supported):
- provision_server, configure_keycloak, configure_minio, etc.
Note: Payload validation is performed agent-side. The orchestrator
accepts any dict payload to allow flexibility and forward compatibility.
"""
tenant_id: uuid.UUID
type: str = Field(
...,
min_length=1,
max_length=100,
description="Task type (FILE_WRITE, ENV_UPDATE, DOCKER_RELOAD, COMPOSITE, etc.)",
)
payload: dict[str, Any] = Field(
default_factory=dict,
description="Task-specific payload (see docstring for formats)",
)
class TaskUpdate(BaseModel):
"""Schema for updating a task (status and result only)."""
status: TaskStatus | None = None
result: dict[str, Any] | None = None
class TaskResponse(BaseModel):
"""Schema for task response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tenant_id: uuid.UUID
agent_id: uuid.UUID | None
type: str
payload: dict[str, Any]
status: str
result: dict[str, Any] | None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,73 @@
"""Extended task payload schemas for SysAdmin Agent automation.
These schemas define the expected payload structure for each task type.
Validation is performed agent-side; the orchestrator accepts any dict payload.
"""
from typing import Any
from pydantic import BaseModel, Field
class FileWritePayload(BaseModel):
"""
Payload for FILE_WRITE task type.
Instructs the agent to write content to a file at the specified path.
"""
path: str = Field(..., description="Absolute path to the target file")
content: str = Field(..., description="Content to write to the file")
class EnvUpdatePayload(BaseModel):
"""
Payload for ENV_UPDATE task type.
Instructs the agent to update key/value pairs in an .env file.
Existing keys are updated; new keys are appended.
"""
path: str = Field(..., description="Absolute path to the .env file")
updates: dict[str, str] = Field(
..., description="Key-value pairs to update or add"
)
class DockerReloadPayload(BaseModel):
"""
Payload for DOCKER_RELOAD task type.
Instructs the agent to reload a Docker Compose stack.
Equivalent to: docker compose down && docker compose up -d
"""
compose_dir: str = Field(
..., description="Directory containing docker-compose.yml"
)
class CompositeSubTask(BaseModel):
"""
A single sub-task within a COMPOSITE task.
Represents one step in a multi-step automation sequence.
"""
task: str = Field(..., description="Task type (e.g., FILE_WRITE, ENV_UPDATE)")
payload: dict[str, Any] = Field(
default_factory=dict, description="Payload for this sub-task"
)
class CompositePayload(BaseModel):
"""
Payload for COMPOSITE task type.
Instructs the agent to execute a sequence of sub-tasks in order.
If any sub-task fails, the sequence stops and the composite task fails.
"""
sequence: list[CompositeSubTask] = Field(
..., description="Ordered list of sub-tasks to execute"
)

View File

@@ -0,0 +1,45 @@
"""Tenant schemas for API validation."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field
if TYPE_CHECKING:
from app.models.tenant import Tenant
class TenantCreate(BaseModel):
"""Schema for creating a new tenant."""
name: str = Field(..., min_length=1, max_length=255)
domain: str | None = Field(None, max_length=255)
class TenantResponse(BaseModel):
"""Schema for tenant response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
domain: str | None
has_dashboard_token: bool = Field(
default=False,
description="Whether a dashboard token has been configured",
)
created_at: datetime
updated_at: datetime
@classmethod
def from_orm_with_token_check(cls, tenant: "Tenant") -> "TenantResponse":
"""Create response with dashboard token check."""
return cls(
id=tenant.id,
name=tenant.name,
domain=tenant.domain,
has_dashboard_token=tenant.dashboard_token_hash is not None,
created_at=tenant.created_at,
updated_at=tenant.updated_at,
)

View File

@@ -0,0 +1,5 @@
"""Service layer for the Orchestrator."""
from app.services.local_bootstrap import LocalBootstrapService
__all__ = ["LocalBootstrapService"]

View File

@@ -0,0 +1,270 @@
"""Hub Telemetry Service - sends aggregated metrics to Hub.
This background service periodically collects metrics from the local database
and sends them to the central Hub for license compliance and usage analytics.
Key design choices:
- Since-last-send windowing (avoids double-counting)
- SQL aggregates (never loads task objects into Python)
- Reusable httpx.AsyncClient (single connection pool)
- Jitter ±15% (prevents thundering herd)
- Exponential backoff on errors (1s → 2s → 4s → ... → 60s max)
"""
import asyncio
import logging
import random
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import httpx
from sqlalchemy import func, select
from app.config import get_settings
from app.db import async_session_maker
from app.models.agent import Agent, AgentStatus
from app.models.server import Server
from app.models.task import Task
logger = logging.getLogger(__name__)
settings = get_settings()
class HubTelemetryService:
"""Background service that sends telemetry to Hub."""
_task: Optional[asyncio.Task] = None
_shutdown_event: Optional[asyncio.Event] = None
_start_time: Optional[datetime] = None
_last_sent_at: Optional[datetime] = None
_client: Optional[httpx.AsyncClient] = None
_consecutive_failures: int = 0
@classmethod
async def start(cls) -> None:
"""Start the telemetry background task. Never blocks startup."""
if not settings.HUB_TELEMETRY_ENABLED:
logger.info("hub_telemetry_disabled")
return
if not settings.HUB_URL:
logger.warning("hub_telemetry_missing_hub_url")
return
if not settings.HUB_API_KEY:
logger.warning("hub_telemetry_missing_hub_api_key")
return
if not settings.INSTANCE_ID:
logger.warning("hub_telemetry_missing_instance_id")
return
now = datetime.now(timezone.utc)
cls._start_time = now
# Initialize window to (now - interval) so first send isn't empty
cls._last_sent_at = now - timedelta(
seconds=settings.HUB_TELEMETRY_INTERVAL_SECONDS
)
cls._shutdown_event = asyncio.Event()
cls._consecutive_failures = 0
cls._client = httpx.AsyncClient(timeout=30.0)
cls._task = asyncio.create_task(cls._telemetry_loop())
logger.info(
"hub_telemetry_started",
extra={
"interval_seconds": settings.HUB_TELEMETRY_INTERVAL_SECONDS,
"hub_url": settings.HUB_URL,
"instance_id": settings.INSTANCE_ID,
},
)
@classmethod
async def stop(cls) -> None:
"""Stop the telemetry background task."""
if cls._shutdown_event:
cls._shutdown_event.set()
if cls._task:
try:
await asyncio.wait_for(cls._task, timeout=5.0)
except asyncio.TimeoutError:
cls._task.cancel()
try:
await cls._task
except asyncio.CancelledError:
pass
if cls._client:
await cls._client.aclose()
cls._client = None
logger.info("hub_telemetry_stopped")
@classmethod
async def _telemetry_loop(cls) -> None:
"""Main telemetry loop with jitter and backoff."""
base_interval = settings.HUB_TELEMETRY_INTERVAL_SECONDS
while not cls._shutdown_event.is_set():
try:
await cls._send_telemetry()
cls._consecutive_failures = 0 # Reset on success
except Exception as e:
cls._consecutive_failures += 1
logger.warning(
"hub_telemetry_send_failed",
extra={
"error": str(e),
"error_type": type(e).__name__,
"consecutive_failures": cls._consecutive_failures,
},
)
# Calculate interval: base ± 15% jitter, with backoff on failures
jitter = random.uniform(-0.15, 0.15) * base_interval
backoff = (
min(2**cls._consecutive_failures, 60)
if cls._consecutive_failures
else 0
)
interval = base_interval + jitter + backoff
try:
await asyncio.wait_for(
cls._shutdown_event.wait(), timeout=interval
)
break # Shutdown requested
except asyncio.TimeoutError:
pass # Normal timeout, continue loop
@classmethod
async def _send_telemetry(cls) -> None:
"""Collect and send telemetry to Hub."""
window_start = cls._last_sent_at
window_end = datetime.now(timezone.utc)
payload = await cls._collect_metrics(window_start, window_end)
response = await cls._client.post(
f"{settings.HUB_URL}/api/v1/instances/{settings.INSTANCE_ID}/telemetry",
json=payload,
headers={"X-Hub-Api-Key": settings.HUB_API_KEY},
)
response.raise_for_status()
# Only update window on success
cls._last_sent_at = window_end
logger.debug(
"hub_telemetry_sent",
extra={
"window_seconds": (window_end - window_start).total_seconds(),
"status_code": response.status_code,
},
)
@classmethod
async def _collect_metrics(
cls, window_start: datetime, window_end: datetime
) -> dict[str, Any]:
"""Collect metrics using SQL aggregates (never load objects)."""
async with async_session_maker() as db:
# Agent counts by status (all agents, not windowed)
agent_result = await db.execute(
select(Agent.status, func.count(Agent.id).label("count")).group_by(
Agent.status
)
)
agent_rows = agent_result.all()
# Task counts by status and type (windowed by updated_at)
# Duration approximated as (updated_at - created_at) for completed/failed tasks
task_result = await db.execute(
select(
Task.status,
Task.type,
func.count(Task.id).label("count"),
func.avg(
func.extract("epoch", Task.updated_at - Task.created_at) * 1000
).label("avg_duration_ms"),
)
.where(Task.updated_at.between(window_start, window_end))
.group_by(Task.status, Task.type)
)
task_rows = task_result.all()
# Server count (simple count, not windowed)
server_count = await db.scalar(select(func.count(Server.id)))
return {
"instance_id": str(settings.INSTANCE_ID),
"window_start": window_start.isoformat(),
"window_end": window_end.isoformat(),
"uptime_seconds": int((window_end - cls._start_time).total_seconds()),
"metrics": {
"agents": cls._format_agent_counts(agent_rows),
"tasks": cls._format_task_counts(task_rows),
"servers": {"total_count": server_count or 0},
},
}
@classmethod
def _format_agent_counts(cls, rows: list) -> dict[str, int]:
"""Format agent count rows into response structure."""
counts = {
"online_count": 0,
"offline_count": 0,
"total_count": 0,
}
for row in rows:
status, count = row.status, row.count
counts["total_count"] += count
if status == AgentStatus.ONLINE:
counts["online_count"] = count
elif status == AgentStatus.OFFLINE:
counts["offline_count"] = count
# INVALID agents are counted in total but not separately
return counts
@classmethod
def _format_task_counts(cls, rows: list) -> dict[str, Any]:
"""Format task count rows into response structure."""
by_status: dict[str, int] = {}
by_type: dict[str, dict[str, Any]] = {}
for row in rows:
status_str = row.status.value if hasattr(row.status, "value") else str(row.status)
type_str = row.type.value if hasattr(row.type, "value") else str(row.type)
count = row.count
avg_duration_ms = row.avg_duration_ms
# Aggregate by status
by_status[status_str] = by_status.get(status_str, 0) + count
# Aggregate by type
if type_str not in by_type:
by_type[type_str] = {"count": 0, "avg_duration_ms": 0}
# Weighted average for duration when merging
existing = by_type[type_str]
total_count = existing["count"] + count
if total_count > 0 and avg_duration_ms is not None:
existing_weighted = existing["avg_duration_ms"] * existing["count"]
new_weighted = avg_duration_ms * count
by_type[type_str]["avg_duration_ms"] = (
existing_weighted + new_weighted
) / total_count
by_type[type_str]["count"] = total_count
# Round durations for cleaner output
for type_data in by_type.values():
type_data["avg_duration_ms"] = round(type_data["avg_duration_ms"], 2)
return {
"by_status": by_status,
"by_type": by_type,
}

View File

@@ -0,0 +1,167 @@
"""
Local bootstrap service for single-tenant mode.
Handles automatic tenant creation when LOCAL_MODE is enabled.
Designed to be migration-safe: gracefully handles cases where
database tables don't exist yet.
"""
import asyncio
import logging
from typing import Optional
from uuid import UUID
from sqlalchemy import select, text
from sqlalchemy.exc import OperationalError, ProgrammingError
from app.config import settings
from app.db import async_session_maker
from app.models import Tenant
logger = logging.getLogger(__name__)
class LocalBootstrapService:
"""
Service for bootstrapping local single-tenant mode.
When LOCAL_MODE=true:
- Waits for database migrations to complete
- Creates or retrieves the local tenant
- Makes tenant_id available for the meta endpoint
When LOCAL_MODE=false:
- Does nothing (multi-tenant mode unchanged)
"""
# Class-level state for meta endpoint access
_local_tenant_id: Optional[UUID] = None
_bootstrap_attempted: bool = False
_bootstrap_error: Optional[str] = None
# Bootstrap configuration
MAX_RETRIES = 30 # Max attempts waiting for migrations
RETRY_DELAY_SECONDS = 2 # Delay between retries
LOCAL_TENANT_NAME = "local"
@classmethod
def get_local_tenant_id(cls) -> Optional[UUID]:
"""Get the local tenant ID if bootstrap succeeded."""
return cls._local_tenant_id
@classmethod
def get_bootstrap_status(cls) -> dict:
"""Get bootstrap status for diagnostics."""
return {
"attempted": cls._bootstrap_attempted,
"success": cls._local_tenant_id is not None,
"tenant_id": str(cls._local_tenant_id) if cls._local_tenant_id else None,
"error": cls._bootstrap_error,
}
@classmethod
async def run(cls) -> None:
"""
Run the bootstrap process.
Only executes if LOCAL_MODE is enabled.
Safe to call multiple times (idempotent).
"""
if not settings.LOCAL_MODE:
logger.debug("LOCAL_MODE is disabled, skipping bootstrap")
return
if cls._bootstrap_attempted:
logger.debug("Bootstrap already attempted, skipping")
return
cls._bootstrap_attempted = True
logger.info("LOCAL_MODE enabled, starting local tenant bootstrap")
# Validate required settings
if not settings.INSTANCE_ID:
cls._bootstrap_error = "INSTANCE_ID is required when LOCAL_MODE is enabled"
logger.error(cls._bootstrap_error)
return
try:
await cls._bootstrap_with_retry()
except Exception as e:
cls._bootstrap_error = str(e)
logger.exception("Bootstrap failed with unexpected error")
@classmethod
async def _bootstrap_with_retry(cls) -> None:
"""
Attempt bootstrap with retry logic for migration safety.
Waits for the tenants table to exist before proceeding.
"""
for attempt in range(1, cls.MAX_RETRIES + 1):
try:
await cls._ensure_local_tenant()
logger.info(f"Local tenant bootstrap succeeded (tenant_id={cls._local_tenant_id})")
return
except (OperationalError, ProgrammingError) as e:
# These errors typically indicate migrations haven't run yet
error_msg = str(e).lower()
if "does not exist" in error_msg or "no such table" in error_msg:
if attempt < cls.MAX_RETRIES:
logger.warning(
f"Database table not ready (attempt {attempt}/{cls.MAX_RETRIES}), "
f"retrying in {cls.RETRY_DELAY_SECONDS}s..."
)
await asyncio.sleep(cls.RETRY_DELAY_SECONDS)
continue
else:
cls._bootstrap_error = f"Migrations did not complete after {cls.MAX_RETRIES} attempts"
logger.error(cls._bootstrap_error)
return
else:
# Unexpected database error
raise
@classmethod
async def _ensure_local_tenant(cls) -> None:
"""
Ensure the local tenant exists.
Creates it if missing, retrieves it if already exists.
"""
async with async_session_maker() as session:
# First, verify we can query the tenants table
# This will fail fast if migrations haven't run
await session.execute(text("SELECT 1 FROM tenants LIMIT 1"))
# Check if local tenant exists
result = await session.execute(
select(Tenant).where(Tenant.name == cls.LOCAL_TENANT_NAME)
)
tenant = result.scalar_one_or_none()
if tenant:
logger.info(f"Local tenant already exists (id={tenant.id})")
cls._local_tenant_id = tenant.id
return
# Create local tenant
tenant = Tenant(
name=cls.LOCAL_TENANT_NAME,
domain=settings.LOCAL_TENANT_DOMAIN,
)
session.add(tenant)
await session.commit()
await session.refresh(tenant)
cls._local_tenant_id = tenant.id
logger.info(f"Created local tenant (id={tenant.id}, domain={settings.LOCAL_TENANT_DOMAIN})")
@classmethod
async def _check_table_exists(cls, table_name: str) -> bool:
"""Check if a database table exists."""
async with async_session_maker() as session:
try:
await session.execute(text(f"SELECT 1 FROM {table_name} LIMIT 1"))
return True
except (OperationalError, ProgrammingError):
return False

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Deploy script for LetsBe Orchestrator
# This handles registry authentication and deployment
set -e
REGISTRY="code.letsbe.solutions"
REGISTRY_USER="${REGISTRY_USER:-matt}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-db51180aa20509cddb2e7d3b34f3b321e382069c}"
echo "Logging into registry..."
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
echo "Pulling latest images..."
docker compose pull
echo "Starting services..."
docker compose up -d
echo "Running migrations..."
sleep 5 # Wait for DB to be ready
docker compose exec -T api alembic upgrade head
echo "Deployment complete!"
docker compose ps

View File

@@ -0,0 +1,40 @@
services:
db:
image: postgres:16-alpine
container_name: orchestrator-db
environment:
POSTGRES_USER: orchestrator
POSTGRES_PASSWORD: orchestrator
POSTGRES_DB: orchestrator
ports:
- "5433:5432" # Host port 5433 to avoid conflict with existing Postgres
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U orchestrator -d orchestrator"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
container_name: orchestrator-api
restart: unless-stopped
ports:
- "127.0.0.1:8100:8000"
environment:
DATABASE_URL: postgresql+asyncpg://orchestrator:orchestrator@db:5432/orchestrator
DEBUG: "true"
APP_NAME: "LetsBe Orchestrator"
ADMIN_API_KEY: "dev-admin-key-12345" # Dev only - generate secure key for prod
depends_on:
db:
condition: service_healthy
volumes:
- ./app:/app/app
- ./alembic:/app/alembic
- ./tests:/app/tests
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
postgres_data:

View File

@@ -0,0 +1,42 @@
# docker-compose.local.yml
#
# Orchestrator configuration for LOCAL_MODE (single-tenant deployment)
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.local.yml up
#
# Or set COMPOSE_FILE environment variable:
# export COMPOSE_FILE=docker-compose.yml:docker-compose.local.yml
# docker compose up
#
# Required environment variables:
# INSTANCE_ID - Unique instance identifier (from Hub activation)
# ADMIN_API_KEY - Admin API key for protected endpoints
# LOCAL_AGENT_KEY - Key for local agent registration (Phase 2)
#
# Optional environment variables:
# HUB_URL - Hub API URL (for telemetry)
# HUB_API_KEY - Hub API key (for telemetry auth)
# LOCAL_TENANT_DOMAIN - Domain for auto-created tenant (default: local.letsbe.cloud)
services:
api:
environment:
# Enable LOCAL_MODE for single-tenant operation
LOCAL_MODE: "true"
# Instance identification (from Hub activation)
# Required: Must be set via environment variable
INSTANCE_ID: "${INSTANCE_ID}"
# Local tenant configuration
LOCAL_TENANT_DOMAIN: "${LOCAL_TENANT_DOMAIN:-local.letsbe.cloud}"
# Local agent registration key (Phase 2)
# Separate from ADMIN_API_KEY - can ONLY register the local agent
LOCAL_AGENT_KEY: "${LOCAL_AGENT_KEY}"
# Hub telemetry (optional)
HUB_URL: "${HUB_URL:-}"
HUB_API_KEY: "${HUB_API_KEY:-}"
HUB_TELEMETRY_ENABLED: "${HUB_TELEMETRY_ENABLED:-false}"

View File

@@ -0,0 +1,22 @@
# Development overrides - automatically merged with docker-compose.yml
# For production, rename this file or use: docker compose -f docker-compose.yml up
services:
api:
build: .
image: orchestrator-dev
volumes:
- ./app:/app/app
- ./alembic:/app/alembic
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
environment:
DEBUG: "true"
# LOCAL_MODE settings (set via env file or command line)
LOCAL_MODE: "${LOCAL_MODE:-false}"
INSTANCE_ID: "${INSTANCE_ID:-}"
LOCAL_AGENT_KEY: "${LOCAL_AGENT_KEY:-}"
# Hub telemetry settings
HUB_TELEMETRY_ENABLED: "${HUB_TELEMETRY_ENABLED:-false}"
HUB_URL: "${HUB_URL:-}"
HUB_API_KEY: "${HUB_API_KEY:-}"
HUB_TELEMETRY_INTERVAL_SECONDS: "${HUB_TELEMETRY_INTERVAL_SECONDS:-60}"

View File

@@ -0,0 +1,42 @@
services:
db:
image: postgres:16-alpine
container_name: orchestrator-db
environment:
POSTGRES_USER: ${POSTGRES_USER:-orchestrator}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
POSTGRES_DB: ${POSTGRES_DB:-orchestrator}
ports:
- "5434:5432" # Port 5434 to avoid conflict with Hub Postgres (5432) and other services
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U orchestrator -d orchestrator"]
interval: 5s
timeout: 5s
retries: 5
api:
image: code.letsbe.solutions/letsbe/orchestrator:latest
container_name: orchestrator-api
restart: unless-stopped
ports:
- "127.0.0.1:8100:8000"
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-orchestrator}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-orchestrator}
DEBUG: "false"
APP_NAME: "LetsBe Orchestrator"
ADMIN_API_KEY: "${ADMIN_API_KEY}"
depends_on:
db:
condition: service_healthy
healthcheck:
# Use Python (curl not available in slim images)
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
volumes:
postgres_data:

View File

@@ -0,0 +1,21 @@
server {
listen 80;
listen [::]:80;
server_name orchestrator.yourdomain.com;
# Allow Certbot challenges
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot; # Use your existing certbot webroot
allow all;
}
# Everything else goes to the orchestrator backend (HTTP only for now)
location / {
proxy_pass http://127.0.0.1:8100;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -0,0 +1,26 @@
# Web Framework
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
# Database
sqlalchemy[asyncio]>=2.0.25
asyncpg>=0.29.0
alembic>=1.13.0
# Serialization & Validation
pydantic>=2.5.0
pydantic-settings>=2.1.0
# Utilities
python-dotenv>=1.0.0
# Rate Limiting
slowapi>=0.1.9
# HTTP Client
httpx>=0.26.0
# Testing
pytest>=8.0.0
pytest-asyncio>=0.23.0
aiosqlite>=0.19.0

View File

@@ -0,0 +1 @@
"""Test suite for letsbe-orchestrator."""

View File

@@ -0,0 +1,168 @@
"""Pytest configuration and fixtures for letsbe-orchestrator tests."""
import asyncio
import hashlib
import uuid
from collections.abc import AsyncGenerator
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.models.base import Base, utc_now
from app.models.tenant import Tenant
from app.models.agent import Agent
from app.models.registration_token import RegistrationToken
# Use in-memory SQLite for testing
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(scope="session")
def event_loop():
"""Create an instance of the default event loop for the test session."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="function")
async def async_engine():
"""Create a test async engine."""
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture(scope="function")
async def db(async_engine) -> AsyncGenerator[AsyncSession, None]:
"""Create a test database session."""
session_factory = async_sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async with session_factory() as session:
yield session
@pytest_asyncio.fixture(scope="function")
async def test_tenant(db: AsyncSession) -> Tenant:
"""Create a test tenant."""
tenant = Tenant(
id=uuid.uuid4(),
name="Test Tenant",
)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return tenant
@pytest_asyncio.fixture(scope="function")
async def test_agent(db: AsyncSession) -> Agent:
"""Create a test agent."""
agent = Agent(
id=uuid.uuid4(),
name="test-agent-host",
version="1.0.0",
status="online",
token="test-token-12345",
)
db.add(agent)
await db.commit()
await db.refresh(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
# --- New fixtures for secure auth testing ---
@pytest_asyncio.fixture(scope="function")
async def test_registration_token(
db: AsyncSession, test_tenant: Tenant
) -> tuple[RegistrationToken, str]:
"""Create a test registration token and return (token_record, plaintext_token)."""
plaintext_token = str(uuid.uuid4())
token_hash = hashlib.sha256(plaintext_token.encode()).hexdigest()
reg_token = RegistrationToken(
id=uuid.uuid4(),
tenant_id=test_tenant.id,
token_hash=token_hash,
description="Test registration token",
max_uses=10,
use_count=0,
)
db.add(reg_token)
await db.commit()
await db.refresh(reg_token)
return reg_token, plaintext_token
@pytest_asyncio.fixture(scope="function")
async def test_agent_with_secret(
db: AsyncSession, test_tenant: Tenant
) -> tuple[Agent, str]:
"""Create a test agent with secret_hash and return (agent, plaintext_secret)."""
plaintext_secret = "test-secret-" + uuid.uuid4().hex[:16]
secret_hash = hashlib.sha256(plaintext_secret.encode()).hexdigest()
agent = Agent(
id=uuid.uuid4(),
tenant_id=test_tenant.id,
name="test-agent-secure",
version="1.0.0",
status="online",
token="", # Empty for new auth scheme
secret_hash=secret_hash,
last_heartbeat=utc_now(),
)
db.add(agent)
await db.commit()
await db.refresh(agent)
return agent, plaintext_secret
@pytest_asyncio.fixture(scope="function")
async def test_agent_legacy(db: AsyncSession, test_tenant: Tenant) -> Agent:
"""Create a test agent with legacy token auth (both token and secret_hash set)."""
legacy_token = "legacy-token-" + uuid.uuid4().hex[:16]
secret_hash = hashlib.sha256(legacy_token.encode()).hexdigest()
agent = Agent(
id=uuid.uuid4(),
tenant_id=test_tenant.id,
name="test-agent-legacy",
version="1.0.0",
status="online",
token=legacy_token, # Legacy field
secret_hash=secret_hash, # Also set for new auth
)
db.add(agent)
await db.commit()
await db.refresh(agent)
return agent

View File

@@ -0,0 +1 @@
"""Tests for route modules."""

View File

@@ -0,0 +1,361 @@
"""Tests for agent authentication endpoints and dependencies."""
import hashlib
import uuid
import pytest
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies.auth import get_current_agent, get_current_agent_compat
from app.models.agent import Agent, AgentStatus
from app.models.registration_token import RegistrationToken
from app.models.tenant import Tenant
from app.routes.agents import (
_register_agent_legacy,
_register_agent_secure,
agent_heartbeat,
)
from app.schemas.agent import AgentRegisterRequest, AgentRegisterRequestLegacy
@pytest.mark.asyncio
class TestGetCurrentAgent:
"""Tests for the get_current_agent dependency (new auth scheme)."""
async def test_valid_credentials(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Successfully authenticate with valid X-Agent-Id and X-Agent-Secret."""
agent, secret = test_agent_with_secret
result = await get_current_agent(
db=db, x_agent_id=str(agent.id), x_agent_secret=secret
)
assert result.id == agent.id
assert result.tenant_id == test_tenant.id
async def test_invalid_agent_id(self, db: AsyncSession):
"""Returns 401 for non-existent agent ID."""
fake_agent_id = str(uuid.uuid4())
with pytest.raises(HTTPException) as exc_info:
await get_current_agent(
db=db, x_agent_id=fake_agent_id, x_agent_secret="any-secret"
)
assert exc_info.value.status_code == 401
assert "Invalid agent credentials" in str(exc_info.value.detail)
async def test_invalid_secret(
self,
db: AsyncSession,
test_agent_with_secret: tuple[Agent, str],
):
"""Returns 401 for wrong secret."""
agent, _ = test_agent_with_secret
with pytest.raises(HTTPException) as exc_info:
await get_current_agent(
db=db, x_agent_id=str(agent.id), x_agent_secret="wrong-secret"
)
assert exc_info.value.status_code == 401
assert "Invalid agent credentials" in str(exc_info.value.detail)
async def test_malformed_agent_id(self, db: AsyncSession):
"""Returns 401 for malformed UUID."""
with pytest.raises(HTTPException) as exc_info:
await get_current_agent(
db=db, x_agent_id="not-a-uuid", x_agent_secret="any-secret"
)
assert exc_info.value.status_code == 401
assert "Invalid Agent ID format" in str(exc_info.value.detail)
@pytest.mark.asyncio
class TestGetCurrentAgentCompat:
"""Tests for the backward-compatible auth dependency."""
async def test_new_scheme_preferred(
self,
db: AsyncSession,
test_agent_with_secret: tuple[Agent, str],
):
"""New X-Agent-* headers take precedence over Bearer."""
agent, secret = test_agent_with_secret
result = await get_current_agent_compat(
db=db,
x_agent_id=str(agent.id),
x_agent_secret=secret,
authorization="Bearer wrong-token", # Should be ignored
)
assert result.id == agent.id
async def test_legacy_bearer_fallback(
self, db: AsyncSession, test_agent_legacy: Agent
):
"""Falls back to Bearer token when X-Agent-* not provided."""
result = await get_current_agent_compat(
db=db,
x_agent_id=None,
x_agent_secret=None,
authorization=f"Bearer {test_agent_legacy.token}",
agent_id=test_agent_legacy.id, # Legacy auth requires agent_id
)
assert result.id == test_agent_legacy.id
async def test_no_credentials_provided(self, db: AsyncSession):
"""Returns 401 when no auth credentials provided."""
with pytest.raises(HTTPException) as exc_info:
await get_current_agent_compat(
db=db, x_agent_id=None, x_agent_secret=None, authorization=None
)
assert exc_info.value.status_code == 401
assert "Missing authentication credentials" in str(exc_info.value.detail)
@pytest.mark.asyncio
class TestSecureAgentRegistration:
"""Tests for the new secure registration flow."""
async def test_registration_with_valid_token(
self,
db: AsyncSession,
test_tenant: Tenant,
test_registration_token: tuple[RegistrationToken, str],
):
"""Successfully register agent with valid registration token."""
_, plaintext_token = test_registration_token
request = AgentRegisterRequest(
hostname="new-agent-host",
version="2.0.0",
registration_token=plaintext_token,
)
response = await _register_agent_secure(request, db)
assert response.agent_id is not None
assert response.agent_secret is not None
assert response.tenant_id == test_tenant.id
# Verify agent was created
result = await db.execute(
select(Agent).where(Agent.id == response.agent_id)
)
agent = result.scalar_one()
assert agent.name == "new-agent-host"
assert agent.tenant_id == test_tenant.id
async def test_registration_increments_use_count(
self,
db: AsyncSession,
test_registration_token: tuple[RegistrationToken, str],
):
"""Registration increments the token's use_count."""
token_record, plaintext_token = test_registration_token
initial_count = token_record.use_count
request = AgentRegisterRequest(
hostname="test-host",
version="1.0.0",
registration_token=plaintext_token,
)
await _register_agent_secure(request, db)
await db.refresh(token_record)
assert token_record.use_count == initial_count + 1
async def test_registration_stores_secret_hash(
self,
db: AsyncSession,
test_registration_token: tuple[RegistrationToken, str],
):
"""Agent secret is stored as hash, not plaintext."""
_, plaintext_token = test_registration_token
request = AgentRegisterRequest(
hostname="test-host",
version="1.0.0",
registration_token=plaintext_token,
)
response = await _register_agent_secure(request, db)
result = await db.execute(
select(Agent).where(Agent.id == response.agent_id)
)
agent = result.scalar_one()
expected_hash = hashlib.sha256(response.agent_secret.encode()).hexdigest()
assert agent.secret_hash == expected_hash
async def test_registration_with_invalid_token(self, db: AsyncSession):
"""Returns 401 for invalid registration token."""
request = AgentRegisterRequest(
hostname="test-host",
version="1.0.0",
registration_token="invalid-token",
)
with pytest.raises(HTTPException) as exc_info:
await _register_agent_secure(request, db)
assert exc_info.value.status_code == 401
assert "Invalid registration token" in str(exc_info.value.detail)
async def test_registration_with_revoked_token(
self,
db: AsyncSession,
test_registration_token: tuple[RegistrationToken, str],
):
"""Returns 401 for revoked registration token."""
token_record, plaintext_token = test_registration_token
token_record.revoked = True
await db.commit()
request = AgentRegisterRequest(
hostname="test-host",
version="1.0.0",
registration_token=plaintext_token,
)
with pytest.raises(HTTPException) as exc_info:
await _register_agent_secure(request, db)
assert exc_info.value.status_code == 401
assert "revoked" in str(exc_info.value.detail).lower()
async def test_registration_with_exhausted_token(
self,
db: AsyncSession,
test_registration_token: tuple[RegistrationToken, str],
):
"""Returns 401 for exhausted registration token."""
token_record, plaintext_token = test_registration_token
token_record.max_uses = 1
token_record.use_count = 1
await db.commit()
request = AgentRegisterRequest(
hostname="test-host",
version="1.0.0",
registration_token=plaintext_token,
)
with pytest.raises(HTTPException) as exc_info:
await _register_agent_secure(request, db)
assert exc_info.value.status_code == 401
assert "exhausted" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
class TestLegacyAgentRegistration:
"""Tests for the legacy registration flow."""
async def test_legacy_registration_success(
self, db: AsyncSession, test_tenant: Tenant
):
"""Successfully register agent using legacy flow."""
request = AgentRegisterRequestLegacy(
hostname="legacy-host",
version="1.0.0",
tenant_id=test_tenant.id,
)
response = await _register_agent_legacy(request, db)
assert response.agent_id is not None
assert response.token is not None
# Verify agent was created with both token and secret_hash
result = await db.execute(
select(Agent).where(Agent.id == response.agent_id)
)
agent = result.scalar_one()
assert agent.token == response.token
assert agent.secret_hash == hashlib.sha256(response.token.encode()).hexdigest()
async def test_legacy_registration_tenant_not_found(self, db: AsyncSession):
"""Returns 404 for non-existent tenant in legacy flow."""
fake_tenant_id = uuid.uuid4()
request = AgentRegisterRequestLegacy(
hostname="legacy-host",
version="1.0.0",
tenant_id=fake_tenant_id,
)
with pytest.raises(HTTPException) as exc_info:
await _register_agent_legacy(request, db)
assert exc_info.value.status_code == 404
async def test_legacy_registration_without_tenant(self, db: AsyncSession):
"""Legacy registration without tenant_id creates shared agent."""
request = AgentRegisterRequestLegacy(
hostname="shared-host",
version="1.0.0",
tenant_id=None,
)
response = await _register_agent_legacy(request, db)
result = await db.execute(
select(Agent).where(Agent.id == response.agent_id)
)
agent = result.scalar_one()
assert agent.tenant_id is None
@pytest.mark.asyncio
class TestAgentHeartbeat:
"""Tests for the agent heartbeat endpoint."""
async def test_heartbeat_success(
self,
db: AsyncSession,
test_agent_with_secret: tuple[Agent, str],
):
"""Successfully send heartbeat updates timestamp and status."""
agent, _ = test_agent_with_secret
old_heartbeat = agent.last_heartbeat
response = await agent_heartbeat(
agent_id=agent.id, db=db, current_agent=agent
)
assert response.status == "ok"
await db.refresh(agent)
assert agent.status == AgentStatus.ONLINE.value
assert agent.last_heartbeat >= old_heartbeat
async def test_heartbeat_agent_id_mismatch(
self,
db: AsyncSession,
test_agent_with_secret: tuple[Agent, str],
):
"""Returns 403 when path agent_id doesn't match authenticated agent."""
agent, _ = test_agent_with_secret
wrong_agent_id = uuid.uuid4()
with pytest.raises(HTTPException) as exc_info:
await agent_heartbeat(
agent_id=wrong_agent_id, db=db, current_agent=agent
)
assert exc_info.value.status_code == 403
assert "Agent ID mismatch" in str(exc_info.value.detail)

View File

@@ -0,0 +1,213 @@
"""Tests for env management 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.routes.env import inspect_env, update_env
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
@pytest.mark.asyncio
class TestInspectEnv:
"""Tests for the inspect_env endpoint."""
async def test_happy_path(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Valid request creates an ENV_INSPECT task."""
request = EnvInspectRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/env/chatwoot.env",
keys=["FRONTEND_URL", "BACKEND_URL"],
)
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
assert task.id is not None
assert task.tenant_id == test_tenant.id
assert task.agent_id == test_agent.id
assert task.type == "ENV_INSPECT"
assert task.status == "pending"
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
assert task.payload["keys"] == ["FRONTEND_URL", "BACKEND_URL"]
async def test_without_keys(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Request without keys omits keys from payload."""
request = EnvInspectRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/env/chatwoot.env",
keys=None,
)
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
assert task.type == "ENV_INSPECT"
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
assert "keys" not in task.payload
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
"""Returns 404 when tenant doesn't exist."""
fake_tenant_id = uuid.uuid4()
request = EnvInspectRequest(
tenant_id=fake_tenant_id,
path="/opt/letsbe/env/chatwoot.env",
)
with pytest.raises(HTTPException) as exc_info:
await inspect_env(agent_id=test_agent.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_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
"""Returns 404 when agent doesn't exist."""
fake_agent_id = uuid.uuid4()
request = EnvInspectRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/env/chatwoot.env",
)
with pytest.raises(HTTPException) as exc_info:
await inspect_env(agent_id=fake_agent_id, request=request, db=db)
assert exc_info.value.status_code == 404
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
async def test_task_persisted_to_database(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Verify the task is actually persisted and can be retrieved."""
request = EnvInspectRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/env/chatwoot.env",
)
task = await inspect_env(agent_id=test_agent.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 == "ENV_INSPECT"
assert retrieved_task.tenant_id == test_tenant.id
@pytest.mark.asyncio
class TestUpdateEnv:
"""Tests for the update_env endpoint."""
async def test_happy_path(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Valid request creates an ENV_UPDATE task."""
request = EnvUpdateRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/env/chatwoot.env",
updates={"FRONTEND_URL": "https://new.domain.com"},
remove_keys=["OLD_KEY"],
)
task = await update_env(agent_id=test_agent.id, request=request, db=db)
assert task.id is not None
assert task.tenant_id == test_tenant.id
assert task.agent_id == test_agent.id
assert task.type == "ENV_UPDATE"
assert task.status == "pending"
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
assert task.payload["updates"] == {"FRONTEND_URL": "https://new.domain.com"}
assert task.payload["remove_keys"] == ["OLD_KEY"]
async def test_updates_only(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Request with only updates omits remove_keys from payload."""
request = EnvUpdateRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/env/chatwoot.env",
updates={"KEY": "VALUE"},
remove_keys=None,
)
task = await update_env(agent_id=test_agent.id, request=request, db=db)
assert task.type == "ENV_UPDATE"
assert task.payload["updates"] == {"KEY": "VALUE"}
assert "remove_keys" not in task.payload
async def test_remove_keys_only(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Request with only remove_keys omits updates from payload."""
request = EnvUpdateRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/env/chatwoot.env",
updates=None,
remove_keys=["OLD_KEY"],
)
task = await update_env(agent_id=test_agent.id, request=request, db=db)
assert task.type == "ENV_UPDATE"
assert "updates" not in task.payload
assert task.payload["remove_keys"] == ["OLD_KEY"]
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
"""Returns 404 when tenant doesn't exist."""
fake_tenant_id = uuid.uuid4()
request = EnvUpdateRequest(
tenant_id=fake_tenant_id,
path="/opt/letsbe/env/chatwoot.env",
updates={"KEY": "VALUE"},
)
with pytest.raises(HTTPException) as exc_info:
await update_env(agent_id=test_agent.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_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
"""Returns 404 when agent doesn't exist."""
fake_agent_id = uuid.uuid4()
request = EnvUpdateRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/env/chatwoot.env",
updates={"KEY": "VALUE"},
)
with pytest.raises(HTTPException) as exc_info:
await update_env(agent_id=fake_agent_id, request=request, db=db)
assert exc_info.value.status_code == 404
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
async def test_task_persisted_to_database(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Verify the task is actually persisted and can be retrieved."""
request = EnvUpdateRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/env/chatwoot.env",
updates={"KEY": "VALUE"},
)
task = await update_env(agent_id=test_agent.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 == "ENV_UPDATE"
assert retrieved_task.tenant_id == test_tenant.id

View File

@@ -0,0 +1,116 @@
"""Tests for file management 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.routes.files import inspect_file
from app.schemas.file import FileInspectRequest
@pytest.mark.asyncio
class TestInspectFile:
"""Tests for the inspect_file endpoint."""
async def test_happy_path(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Valid request creates a FILE_INSPECT task."""
request = FileInspectRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/data/config.json",
max_bytes=4096,
)
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
assert task.id is not None
assert task.tenant_id == test_tenant.id
assert task.agent_id == test_agent.id
assert task.type == "FILE_INSPECT"
assert task.status == "pending"
assert task.payload["path"] == "/opt/letsbe/data/config.json"
assert task.payload["max_bytes"] == 4096
async def test_with_default_max_bytes(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Request uses default max_bytes of 4096."""
request = FileInspectRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/data/config.json",
)
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
assert task.type == "FILE_INSPECT"
assert task.payload["path"] == "/opt/letsbe/data/config.json"
assert task.payload["max_bytes"] == 4096
async def test_with_custom_max_bytes(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Request with custom max_bytes is respected."""
request = FileInspectRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/data/large_file.txt",
max_bytes=1048576, # 1MB
)
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
assert task.type == "FILE_INSPECT"
assert task.payload["max_bytes"] == 1048576
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
"""Returns 404 when tenant doesn't exist."""
fake_tenant_id = uuid.uuid4()
request = FileInspectRequest(
tenant_id=fake_tenant_id,
path="/opt/letsbe/data/config.json",
)
with pytest.raises(HTTPException) as exc_info:
await inspect_file(agent_id=test_agent.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_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
"""Returns 404 when agent doesn't exist."""
fake_agent_id = uuid.uuid4()
request = FileInspectRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/data/config.json",
)
with pytest.raises(HTTPException) as exc_info:
await inspect_file(agent_id=fake_agent_id, request=request, db=db)
assert exc_info.value.status_code == 404
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
async def test_task_persisted_to_database(
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
):
"""Verify the task is actually persisted and can be retrieved."""
request = FileInspectRequest(
tenant_id=test_tenant.id,
path="/opt/letsbe/data/config.json",
)
task = await inspect_file(agent_id=test_agent.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 == "FILE_INSPECT"
assert retrieved_task.tenant_id == test_tenant.id

View File

@@ -0,0 +1,492 @@
"""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
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 (
NextcloudInitialSetupRequest,
NextcloudSetDomainRequest,
nextcloud_initial_setup,
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
# =============================================================================
# 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"
# allowed_domains should be in options, not inputs
assert task.payload["options"]["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

View File

@@ -0,0 +1,300 @@
"""Tests for registration token endpoints."""
import hashlib
import uuid
from datetime import timedelta
import pytest
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.base import utc_now
from app.models.registration_token import RegistrationToken
from app.models.tenant import Tenant
from app.routes.registration_tokens import (
create_registration_token,
get_registration_token,
list_registration_tokens,
revoke_registration_token,
)
from app.schemas.registration_token import RegistrationTokenCreate
@pytest.mark.asyncio
class TestCreateRegistrationToken:
"""Tests for the create_registration_token endpoint."""
async def test_create_token_success(self, db: AsyncSession, test_tenant: Tenant):
"""Successfully create a registration token."""
request = RegistrationTokenCreate(
description="Test token",
max_uses=5,
expires_in_hours=24,
)
response = await create_registration_token(
tenant_id=test_tenant.id, request=request, db=db
)
assert response.id is not None
assert response.tenant_id == test_tenant.id
assert response.description == "Test token"
assert response.max_uses == 5
assert response.use_count == 0
assert response.revoked is False
assert response.token is not None # Plaintext token returned once
assert response.expires_at is not None
async def test_create_token_stores_hash(self, db: AsyncSession, test_tenant: Tenant):
"""Token is stored as hash, not plaintext."""
request = RegistrationTokenCreate(description="Hash test")
response = await create_registration_token(
tenant_id=test_tenant.id, request=request, db=db
)
# Retrieve from database
result = await db.execute(
select(RegistrationToken).where(RegistrationToken.id == response.id)
)
token_record = result.scalar_one()
# Verify hash is stored, not plaintext
expected_hash = hashlib.sha256(response.token.encode()).hexdigest()
assert token_record.token_hash == expected_hash
assert token_record.token_hash != response.token
async def test_create_token_default_values(
self, db: AsyncSession, test_tenant: Tenant
):
"""Default values are applied correctly."""
request = RegistrationTokenCreate()
response = await create_registration_token(
tenant_id=test_tenant.id, request=request, db=db
)
assert response.max_uses == 1 # Default
assert response.description is None
assert response.expires_at is None
async def test_create_token_tenant_not_found(self, db: AsyncSession):
"""Returns 404 when tenant doesn't exist."""
fake_tenant_id = uuid.uuid4()
request = RegistrationTokenCreate()
with pytest.raises(HTTPException) as exc_info:
await create_registration_token(
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)
@pytest.mark.asyncio
class TestListRegistrationTokens:
"""Tests for the list_registration_tokens endpoint."""
async def test_list_tokens_empty(self, db: AsyncSession, test_tenant: Tenant):
"""Returns empty list when no tokens exist."""
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
assert response.tokens == []
assert response.total == 0
async def test_list_tokens_multiple(self, db: AsyncSession, test_tenant: Tenant):
"""Returns all tokens for tenant."""
# Create multiple tokens
for i in range(3):
token = RegistrationToken(
tenant_id=test_tenant.id,
token_hash=f"hash-{i}",
description=f"Token {i}",
)
db.add(token)
await db.commit()
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
assert len(response.tokens) == 3
assert response.total == 3
async def test_list_tokens_tenant_isolation(
self, db: AsyncSession, test_tenant: Tenant
):
"""Only returns tokens for the specified tenant."""
# Create another tenant
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
db.add(other_tenant)
# Create token for test_tenant
token1 = RegistrationToken(
tenant_id=test_tenant.id, token_hash="hash-1", description="Tenant 1"
)
# Create token for other_tenant
token2 = RegistrationToken(
tenant_id=other_tenant.id, token_hash="hash-2", description="Tenant 2"
)
db.add_all([token1, token2])
await db.commit()
response = await list_registration_tokens(tenant_id=test_tenant.id, db=db)
assert len(response.tokens) == 1
assert response.tokens[0].description == "Tenant 1"
async def test_list_tokens_tenant_not_found(self, db: AsyncSession):
"""Returns 404 when tenant doesn't exist."""
fake_tenant_id = uuid.uuid4()
with pytest.raises(HTTPException) as exc_info:
await list_registration_tokens(tenant_id=fake_tenant_id, db=db)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
class TestGetRegistrationToken:
"""Tests for the get_registration_token endpoint."""
async def test_get_token_success(
self,
db: AsyncSession,
test_tenant: Tenant,
test_registration_token: tuple[RegistrationToken, str],
):
"""Successfully retrieve a token."""
token_record, _ = test_registration_token
response = await get_registration_token(
tenant_id=test_tenant.id, token_id=token_record.id, db=db
)
assert response.id == token_record.id
assert response.description == token_record.description
async def test_get_token_not_found(self, db: AsyncSession, test_tenant: Tenant):
"""Returns 404 when token doesn't exist."""
fake_token_id = uuid.uuid4()
with pytest.raises(HTTPException) as exc_info:
await get_registration_token(
tenant_id=test_tenant.id, token_id=fake_token_id, db=db
)
assert exc_info.value.status_code == 404
async def test_get_token_wrong_tenant(
self,
db: AsyncSession,
test_tenant: Tenant,
test_registration_token: tuple[RegistrationToken, str],
):
"""Returns 404 when token belongs to different tenant."""
token_record, _ = test_registration_token
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
db.add(other_tenant)
await db.commit()
with pytest.raises(HTTPException) as exc_info:
await get_registration_token(
tenant_id=other_tenant.id, token_id=token_record.id, db=db
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
class TestRevokeRegistrationToken:
"""Tests for the revoke_registration_token endpoint."""
async def test_revoke_token_success(
self,
db: AsyncSession,
test_tenant: Tenant,
test_registration_token: tuple[RegistrationToken, str],
):
"""Successfully revoke a token."""
token_record, _ = test_registration_token
assert token_record.revoked is False
await revoke_registration_token(
tenant_id=test_tenant.id, token_id=token_record.id, db=db
)
# Refresh to see updated value
await db.refresh(token_record)
assert token_record.revoked is True
async def test_revoke_token_not_found(self, db: AsyncSession, test_tenant: Tenant):
"""Returns 404 when token doesn't exist."""
fake_token_id = uuid.uuid4()
with pytest.raises(HTTPException) as exc_info:
await revoke_registration_token(
tenant_id=test_tenant.id, token_id=fake_token_id, db=db
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
class TestRegistrationTokenIsValid:
"""Tests for the RegistrationToken.is_valid() method."""
async def test_valid_token(
self,
db: AsyncSession,
test_tenant: Tenant,
test_registration_token: tuple[RegistrationToken, str],
):
"""Token is valid when not revoked, not expired, and not exhausted."""
token_record, _ = test_registration_token
assert token_record.is_valid() is True
async def test_revoked_token_invalid(
self,
db: AsyncSession,
test_tenant: Tenant,
test_registration_token: tuple[RegistrationToken, str],
):
"""Revoked token is invalid."""
token_record, _ = test_registration_token
token_record.revoked = True
await db.commit()
assert token_record.is_valid() is False
async def test_expired_token_invalid(self, db: AsyncSession, test_tenant: Tenant):
"""Expired token is invalid."""
token = RegistrationToken(
tenant_id=test_tenant.id,
token_hash="test-hash",
expires_at=utc_now() - timedelta(hours=1),
)
db.add(token)
await db.commit()
assert token.is_valid() is False
async def test_exhausted_token_invalid(self, db: AsyncSession, test_tenant: Tenant):
"""Token that reached max_uses is invalid."""
token = RegistrationToken(
tenant_id=test_tenant.id, token_hash="test-hash", max_uses=3, use_count=3
)
db.add(token)
await db.commit()
assert token.is_valid() is False
async def test_unlimited_uses_token(self, db: AsyncSession, test_tenant: Tenant):
"""Token with max_uses=0 has unlimited uses."""
token = RegistrationToken(
tenant_id=test_tenant.id, token_hash="test-hash", max_uses=0, use_count=1000
)
db.add(token)
await db.commit()
assert token.is_valid() is True

View File

@@ -0,0 +1,396 @@
"""Tests for task endpoints with authentication."""
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, TaskStatus
from app.models.tenant import Tenant
from app.routes.tasks import (
get_next_pending_task,
get_next_task_endpoint,
update_task_endpoint,
)
from app.schemas.task import TaskUpdate
@pytest.mark.asyncio
class TestGetNextPendingTask:
"""Tests for the get_next_pending_task helper function."""
async def test_returns_pending_task(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Returns oldest pending task for agent's tenant."""
agent, _ = test_agent_with_secret
# Create tasks
task1 = Task(
tenant_id=test_tenant.id,
type="TEST",
payload={"order": 1},
status=TaskStatus.PENDING.value,
)
task2 = Task(
tenant_id=test_tenant.id,
type="TEST",
payload={"order": 2},
status=TaskStatus.PENDING.value,
)
db.add_all([task1, task2])
await db.commit()
result = await get_next_pending_task(db, agent)
# Should return oldest (task1)
assert result is not None
assert result.payload["order"] == 1
async def test_filters_by_tenant(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Only returns tasks for agent's tenant."""
agent, _ = test_agent_with_secret
# Create tenant for another tenant
other_tenant = Tenant(id=uuid.uuid4(), name="Other Tenant")
db.add(other_tenant)
# Create task for other tenant
other_task = Task(
tenant_id=other_tenant.id,
type="TEST",
payload={},
status=TaskStatus.PENDING.value,
)
db.add(other_task)
await db.commit()
result = await get_next_pending_task(db, agent)
# Should not see other tenant's task
assert result is None
async def test_returns_none_when_empty(
self,
db: AsyncSession,
test_agent_with_secret: tuple[Agent, str],
):
"""Returns None when no pending tasks exist."""
agent, _ = test_agent_with_secret
result = await get_next_pending_task(db, agent)
assert result is None
async def test_skips_non_pending_tasks(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Only returns tasks with PENDING status."""
agent, _ = test_agent_with_secret
# Create running task
running_task = Task(
tenant_id=test_tenant.id,
type="TEST",
payload={},
status=TaskStatus.RUNNING.value,
)
db.add(running_task)
await db.commit()
result = await get_next_pending_task(db, agent)
assert result is None
@pytest.mark.asyncio
class TestGetNextTaskEndpoint:
"""Tests for the GET /tasks/next endpoint."""
async def test_claims_task(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Successfully claims a pending task."""
agent, _ = test_agent_with_secret
task = Task(
tenant_id=test_tenant.id,
type="TEST",
payload={},
status=TaskStatus.PENDING.value,
)
db.add(task)
await db.commit()
result = await get_next_task_endpoint(db=db, current_agent=agent)
assert result is not None
assert result.status == TaskStatus.RUNNING.value
assert result.agent_id == agent.id
async def test_returns_none_when_empty(
self,
db: AsyncSession,
test_agent_with_secret: tuple[Agent, str],
):
"""Returns None when no tasks available."""
agent, _ = test_agent_with_secret
result = await get_next_task_endpoint(db=db, current_agent=agent)
assert result is None
async def test_tenant_isolation(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Agent can only claim tasks from its tenant."""
agent, _ = test_agent_with_secret
# Create task for different tenant
other_tenant = Tenant(id=uuid.uuid4(), name="Other")
db.add(other_tenant)
other_task = Task(
tenant_id=other_tenant.id,
type="TEST",
payload={},
status=TaskStatus.PENDING.value,
)
db.add(other_task)
await db.commit()
result = await get_next_task_endpoint(db=db, current_agent=agent)
# Should not see other tenant's task
assert result is None
@pytest.mark.asyncio
class TestUpdateTaskEndpoint:
"""Tests for the PATCH /tasks/{task_id} endpoint."""
async def test_update_task_success(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Successfully update an assigned task."""
agent, _ = test_agent_with_secret
task = Task(
tenant_id=test_tenant.id,
type="TEST",
payload={},
status=TaskStatus.RUNNING.value,
agent_id=agent.id, # Assigned to this agent
)
db.add(task)
await db.commit()
update = TaskUpdate(status=TaskStatus.COMPLETED, result={"success": True})
result = await update_task_endpoint(
task_id=task.id, task_update=update, db=db, current_agent=agent
)
assert result.status == TaskStatus.COMPLETED.value
assert result.result == {"success": True}
async def test_update_task_not_found(
self,
db: AsyncSession,
test_agent_with_secret: tuple[Agent, str],
):
"""Returns 404 for non-existent task."""
agent, _ = test_agent_with_secret
fake_task_id = uuid.uuid4()
update = TaskUpdate(status=TaskStatus.COMPLETED)
with pytest.raises(HTTPException) as exc_info:
await update_task_endpoint(
task_id=fake_task_id, task_update=update, db=db, current_agent=agent
)
assert exc_info.value.status_code == 404
async def test_update_task_wrong_tenant(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Returns 403 when task belongs to different tenant."""
agent, _ = test_agent_with_secret
# Create task for different tenant
other_tenant = Tenant(id=uuid.uuid4(), name="Other")
db.add(other_tenant)
other_task = Task(
tenant_id=other_tenant.id,
type="TEST",
payload={},
status=TaskStatus.RUNNING.value,
agent_id=agent.id, # Even if assigned to agent
)
db.add(other_task)
await db.commit()
update = TaskUpdate(status=TaskStatus.COMPLETED)
with pytest.raises(HTTPException) as exc_info:
await update_task_endpoint(
task_id=other_task.id, task_update=update, db=db, current_agent=agent
)
assert exc_info.value.status_code == 403
assert "does not belong to this tenant" in str(exc_info.value.detail)
async def test_update_task_not_assigned(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Returns 403 when task is not assigned to requesting agent."""
agent, _ = test_agent_with_secret
# Create task assigned to different agent
other_agent_id = uuid.uuid4()
task = Task(
tenant_id=test_tenant.id,
type="TEST",
payload={},
status=TaskStatus.RUNNING.value,
agent_id=other_agent_id, # Different agent
)
db.add(task)
await db.commit()
update = TaskUpdate(status=TaskStatus.COMPLETED)
with pytest.raises(HTTPException) as exc_info:
await update_task_endpoint(
task_id=task.id, task_update=update, db=db, current_agent=agent
)
assert exc_info.value.status_code == 403
assert "not assigned to this agent" in str(exc_info.value.detail)
async def test_update_task_unassigned_forbidden(
self,
db: AsyncSession,
test_tenant: Tenant,
test_agent_with_secret: tuple[Agent, str],
):
"""Returns 403 when task has no agent_id assigned."""
agent, _ = test_agent_with_secret
task = Task(
tenant_id=test_tenant.id,
type="TEST",
payload={},
status=TaskStatus.PENDING.value,
agent_id=None, # Not assigned
)
db.add(task)
await db.commit()
update = TaskUpdate(status=TaskStatus.COMPLETED)
with pytest.raises(HTTPException) as exc_info:
await update_task_endpoint(
task_id=task.id, task_update=update, db=db, current_agent=agent
)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
class TestSharedAgentBehavior:
"""Tests for agents without tenant_id (shared agents)."""
async def test_shared_agent_can_claim_any_task(
self, db: AsyncSession, test_tenant: Tenant
):
"""Shared agent (no tenant_id) can claim tasks from any tenant."""
# Create shared agent (no tenant_id)
shared_agent = Agent(
id=uuid.uuid4(),
name="shared-agent",
version="1.0.0",
status="online",
token="shared-token",
secret_hash="dummy-hash",
tenant_id=None,
)
db.add(shared_agent)
task = Task(
tenant_id=test_tenant.id,
type="TEST",
payload={},
status=TaskStatus.PENDING.value,
)
db.add(task)
await db.commit()
result = await get_next_task_endpoint(db=db, current_agent=shared_agent)
assert result is not None
assert result.agent_id == shared_agent.id
async def test_shared_agent_can_update_any_tenant_task(
self, db: AsyncSession, test_tenant: Tenant
):
"""Shared agent can update tasks from any tenant if assigned."""
shared_agent = Agent(
id=uuid.uuid4(),
name="shared-agent",
version="1.0.0",
status="online",
token="shared-token",
secret_hash="dummy-hash",
tenant_id=None,
)
db.add(shared_agent)
task = Task(
tenant_id=test_tenant.id,
type="TEST",
payload={},
status=TaskStatus.RUNNING.value,
agent_id=shared_agent.id,
)
db.add(task)
await db.commit()
update = TaskUpdate(status=TaskStatus.COMPLETED)
result = await update_task_endpoint(
task_id=task.id, task_update=update, db=db, current_agent=shared_agent
)
assert result.status == TaskStatus.COMPLETED.value

View File

@@ -0,0 +1,192 @@
"""Tests for the events route module."""
import uuid
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.event import Event
from app.models.tenant import Tenant
@pytest.mark.asyncio
class TestEventModel:
"""Tests for Event model creation and retrieval."""
async def test_create_event(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that an event can be created with required fields."""
event = Event(
tenant_id=test_tenant.id,
event_type="agent.registered",
payload={"agent_name": "test-agent", "version": "1.0.0"},
)
db.add(event)
await db.commit()
await db.refresh(event)
assert event.id is not None
assert event.tenant_id == test_tenant.id
assert event.event_type == "agent.registered"
assert event.payload["agent_name"] == "test-agent"
assert event.created_at is not None
async def test_create_event_with_task_id(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that an event can be created with an optional task_id."""
task_id = uuid.uuid4()
event = Event(
tenant_id=test_tenant.id,
task_id=task_id,
event_type="task.completed",
payload={"result": "success"},
)
db.add(event)
await db.commit()
await db.refresh(event)
assert event.task_id == task_id
async def test_create_event_without_task_id(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that task_id defaults to None."""
event = Event(
tenant_id=test_tenant.id,
event_type="system.startup",
payload={},
)
db.add(event)
await db.commit()
await db.refresh(event)
assert event.task_id is None
async def test_create_event_with_empty_payload(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that events can have empty payloads."""
event = Event(
tenant_id=test_tenant.id,
event_type="heartbeat",
payload={},
)
db.add(event)
await db.commit()
await db.refresh(event)
assert event.payload == {}
async def test_create_event_with_complex_payload(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that events can have complex nested payloads."""
payload = {
"metrics": {
"cpu_usage": 45.2,
"memory_mb": 1024,
"disk_io": {"read": 100, "write": 200},
},
"tags": ["production", "high-priority"],
"count": 42,
}
event = Event(
tenant_id=test_tenant.id,
event_type="metrics.collected",
payload=payload,
)
db.add(event)
await db.commit()
await db.refresh(event)
assert event.payload["metrics"]["cpu_usage"] == 45.2
assert event.payload["tags"] == ["production", "high-priority"]
async def test_retrieve_event_by_id(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that an event can be retrieved by its ID."""
event = Event(
tenant_id=test_tenant.id,
event_type="test.retrieve",
payload={"key": "value"},
)
db.add(event)
await db.commit()
await db.refresh(event)
result = await db.execute(select(Event).where(Event.id == event.id))
retrieved = result.scalar_one_or_none()
assert retrieved is not None
assert retrieved.event_type == "test.retrieve"
assert retrieved.payload["key"] == "value"
async def test_filter_events_by_type(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that events can be filtered by event_type."""
# Create events of different types
for event_type in ["agent.registered", "task.completed", "agent.registered"]:
event = Event(
tenant_id=test_tenant.id,
event_type=event_type,
payload={},
)
db.add(event)
await db.commit()
result = await db.execute(
select(Event).where(Event.event_type == "agent.registered")
)
events = list(result.scalars().all())
assert len(events) == 2
async def test_filter_events_by_tenant(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that events can be filtered by tenant_id."""
# Create event for test tenant
event = Event(
tenant_id=test_tenant.id,
event_type="test.tenant_filter",
payload={},
)
db.add(event)
await db.commit()
result = await db.execute(
select(Event).where(Event.tenant_id == test_tenant.id)
)
events = list(result.scalars().all())
assert len(events) >= 1
assert all(e.tenant_id == test_tenant.id for e in events)
async def test_events_ordered_by_created_at(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that events can be ordered by created_at."""
for i in range(3):
event = Event(
tenant_id=test_tenant.id,
event_type=f"test.order_{i}",
payload={"index": i},
)
db.add(event)
await db.commit()
result = await db.execute(
select(Event)
.where(Event.tenant_id == test_tenant.id)
.order_by(Event.created_at.desc())
)
events = list(result.scalars().all())
assert len(events) >= 3
async def test_event_repr(self, db: AsyncSession, test_tenant: Tenant):
"""Verify the __repr__ method of Event."""
event = Event(
tenant_id=test_tenant.id,
event_type="test.repr",
payload={},
)
db.add(event)
await db.commit()
await db.refresh(event)
repr_str = repr(event)
assert "Event" in repr_str
assert "test.repr" in repr_str

View File

@@ -0,0 +1,254 @@
"""Tests for the Hub Telemetry service."""
import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.hub_telemetry import HubTelemetryService
class TestHubTelemetryServiceStart:
"""Tests for the start/stop lifecycle of HubTelemetryService."""
def _reset_service(self):
"""Reset class state between tests."""
HubTelemetryService._task = None
HubTelemetryService._shutdown_event = None
HubTelemetryService._start_time = None
HubTelemetryService._last_sent_at = None
HubTelemetryService._client = None
HubTelemetryService._consecutive_failures = 0
@pytest.mark.asyncio
async def test_start_skips_when_telemetry_disabled(self):
"""Verify start() does nothing when HUB_TELEMETRY_ENABLED is False."""
self._reset_service()
with patch("app.services.hub_telemetry.settings") as mock_settings:
mock_settings.HUB_TELEMETRY_ENABLED = False
await HubTelemetryService.start()
assert HubTelemetryService._task is None
assert HubTelemetryService._client is None
@pytest.mark.asyncio
async def test_start_skips_when_hub_url_missing(self):
"""Verify start() does nothing when HUB_URL is not set."""
self._reset_service()
with patch("app.services.hub_telemetry.settings") as mock_settings:
mock_settings.HUB_TELEMETRY_ENABLED = True
mock_settings.HUB_URL = None
await HubTelemetryService.start()
assert HubTelemetryService._task is None
@pytest.mark.asyncio
async def test_start_skips_when_hub_api_key_missing(self):
"""Verify start() does nothing when HUB_API_KEY is not set."""
self._reset_service()
with patch("app.services.hub_telemetry.settings") as mock_settings:
mock_settings.HUB_TELEMETRY_ENABLED = True
mock_settings.HUB_URL = "https://hub.example.com"
mock_settings.HUB_API_KEY = None
await HubTelemetryService.start()
assert HubTelemetryService._task is None
@pytest.mark.asyncio
async def test_start_skips_when_instance_id_missing(self):
"""Verify start() does nothing when INSTANCE_ID is not set."""
self._reset_service()
with patch("app.services.hub_telemetry.settings") as mock_settings:
mock_settings.HUB_TELEMETRY_ENABLED = True
mock_settings.HUB_URL = "https://hub.example.com"
mock_settings.HUB_API_KEY = "test-key"
mock_settings.INSTANCE_ID = None
await HubTelemetryService.start()
assert HubTelemetryService._task is None
@pytest.mark.asyncio
async def test_stop_without_start(self):
"""Verify stop() handles gracefully when service was never started."""
self._reset_service()
# Should not raise
await HubTelemetryService.stop()
class TestHubTelemetryFormatters:
"""Tests for the metric formatting class methods."""
def test_format_agent_counts_empty(self):
"""Verify _format_agent_counts handles empty rows."""
result = HubTelemetryService._format_agent_counts([])
assert result == {
"online_count": 0,
"offline_count": 0,
"total_count": 0,
}
def test_format_agent_counts_with_online_agents(self):
"""Verify _format_agent_counts counts online agents correctly."""
# Create mock rows that mimic SQLAlchemy result rows
from enum import Enum
class MockAgentStatus(str, Enum):
ONLINE = "online"
OFFLINE = "offline"
# Patch AgentStatus for comparison
with patch("app.services.hub_telemetry.AgentStatus") as mock_status:
mock_status.ONLINE = MockAgentStatus.ONLINE
mock_status.OFFLINE = MockAgentStatus.OFFLINE
online_row = MagicMock()
online_row.status = MockAgentStatus.ONLINE
online_row.count = 3
offline_row = MagicMock()
offline_row.status = MockAgentStatus.OFFLINE
offline_row.count = 1
result = HubTelemetryService._format_agent_counts([online_row, offline_row])
assert result["online_count"] == 3
assert result["offline_count"] == 1
assert result["total_count"] == 4
def test_format_task_counts_empty(self):
"""Verify _format_task_counts handles empty rows."""
result = HubTelemetryService._format_task_counts([])
assert result == {
"by_status": {},
"by_type": {},
}
def test_format_task_counts_with_data(self):
"""Verify _format_task_counts aggregates correctly."""
row1 = MagicMock()
row1.status = "completed"
row1.type = "SHELL"
row1.count = 5
row1.avg_duration_ms = 1500.0
row2 = MagicMock()
row2.status = "failed"
row2.type = "SHELL"
row2.count = 2
row2.avg_duration_ms = 3000.0
row3 = MagicMock()
row3.status = "completed"
row3.type = "DOCKER_RELOAD"
row3.count = 3
row3.avg_duration_ms = 5000.0
result = HubTelemetryService._format_task_counts([row1, row2, row3])
# Check by_status aggregation
assert result["by_status"]["completed"] == 8 # 5 + 3
assert result["by_status"]["failed"] == 2
# Check by_type aggregation
assert result["by_type"]["SHELL"]["count"] == 7 # 5 + 2
assert result["by_type"]["DOCKER_RELOAD"]["count"] == 3
def test_format_task_counts_handles_none_duration(self):
"""Verify _format_task_counts handles None avg_duration_ms."""
row = MagicMock()
row.status = "pending"
row.type = "ECHO"
row.count = 1
row.avg_duration_ms = None
result = HubTelemetryService._format_task_counts([row])
assert result["by_type"]["ECHO"]["count"] == 1
assert result["by_type"]["ECHO"]["avg_duration_ms"] == 0
def test_format_task_counts_rounds_durations(self):
"""Verify _format_task_counts rounds avg_duration_ms to 2 decimals."""
row = MagicMock()
row.status = "completed"
row.type = "SHELL"
row.count = 1
row.avg_duration_ms = 1234.56789
result = HubTelemetryService._format_task_counts([row])
assert result["by_type"]["SHELL"]["avg_duration_ms"] == 1234.57
def test_format_task_counts_weighted_average(self):
"""Verify _format_task_counts computes weighted average across same type."""
# Two rows for same type: SHELL completed (count=2, avg=1000) and SHELL failed (count=3, avg=2000)
row1 = MagicMock()
row1.status = "completed"
row1.type = "SHELL"
row1.count = 2
row1.avg_duration_ms = 1000.0
row2 = MagicMock()
row2.status = "failed"
row2.type = "SHELL"
row2.count = 3
row2.avg_duration_ms = 2000.0
result = HubTelemetryService._format_task_counts([row1, row2])
# Weighted avg: (2*1000 + 3*2000) / (2+3) = 8000/5 = 1600.0
assert result["by_type"]["SHELL"]["count"] == 5
assert result["by_type"]["SHELL"]["avg_duration_ms"] == 1600.0
def test_format_task_counts_with_enum_values(self):
"""Verify _format_task_counts handles status/type with .value attribute."""
from enum import Enum
class MockStatus(Enum):
COMPLETED = "completed"
class MockType(Enum):
SHELL = "SHELL"
row = MagicMock()
row.status = MockStatus.COMPLETED
row.type = MockType.SHELL
row.count = 1
row.avg_duration_ms = 500.0
result = HubTelemetryService._format_task_counts([row])
assert "completed" in result["by_status"]
assert "SHELL" in result["by_type"]
class TestHubTelemetryBackoff:
"""Tests for backoff and jitter behavior."""
def test_consecutive_failures_reset_on_init(self):
"""Verify consecutive_failures starts at 0."""
HubTelemetryService._consecutive_failures = 5
HubTelemetryService._consecutive_failures = 0
assert HubTelemetryService._consecutive_failures == 0
def test_backoff_calculation(self):
"""Verify exponential backoff formula."""
# Backoff is min(2^failures, 60)
assert min(2**0, 60) == 1
assert min(2**1, 60) == 2
assert min(2**2, 60) == 4
assert min(2**3, 60) == 8
assert min(2**6, 60) == 60 # Capped at 60
assert min(2**10, 60) == 60 # Still capped

View File

@@ -0,0 +1,314 @@
"""
Tests for LOCAL_MODE behavior.
Verifies:
1. Multi-tenant mode (LOCAL_MODE=false) is unchanged
2. Local mode (LOCAL_MODE=true) bootstrap works correctly
3. Meta endpoint is stable in all scenarios
"""
import pytest
from unittest.mock import patch, AsyncMock
from uuid import uuid4
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.config import Settings
from app.main import app
from app.models import Tenant
from app.services.local_bootstrap import LocalBootstrapService
class TestMultiTenantModeUnchanged:
"""
Tests that multi-tenant mode (LOCAL_MODE=false, the default) remains unchanged.
Key assertions:
- No automatic tenant creation on startup
- Tenants must be created manually via API
- Bootstrap service does nothing
"""
def test_local_mode_default_is_false(self):
"""Verify LOCAL_MODE defaults to False."""
settings = Settings()
assert settings.LOCAL_MODE is False
def test_bootstrap_service_skips_when_local_mode_false(self):
"""Verify bootstrap does nothing when LOCAL_MODE=false."""
# Reset class state
LocalBootstrapService._local_tenant_id = None
LocalBootstrapService._bootstrap_attempted = False
LocalBootstrapService._bootstrap_error = None
# Patch settings in the bootstrap module where it's used
with patch('app.services.local_bootstrap.settings') as mock_settings:
mock_settings.LOCAL_MODE = False
import asyncio
asyncio.get_event_loop().run_until_complete(LocalBootstrapService.run())
# Should not have attempted anything
assert LocalBootstrapService._bootstrap_attempted is False
assert LocalBootstrapService._local_tenant_id is None
def test_meta_endpoint_in_multi_tenant_mode(self):
"""Verify meta endpoint returns correct values in multi-tenant mode."""
# Reset bootstrap state
LocalBootstrapService._local_tenant_id = None
LocalBootstrapService._bootstrap_attempted = False
LocalBootstrapService._bootstrap_error = None
with patch('app.config.settings') as mock_settings:
mock_settings.LOCAL_MODE = False
mock_settings.INSTANCE_ID = None
mock_settings.APP_VERSION = "0.1.0"
client = TestClient(app)
response = client.get("/api/v1/meta/instance")
assert response.status_code == 200
data = response.json()
assert data["local_mode"] is False
assert data["tenant_id"] is None
# instance_id may or may not be set depending on config
class TestLocalModeBootstrap:
"""
Tests for LOCAL_MODE=true bootstrap behavior.
"""
def test_bootstrap_requires_instance_id(self):
"""Verify bootstrap fails gracefully without INSTANCE_ID."""
# Reset class state
LocalBootstrapService._local_tenant_id = None
LocalBootstrapService._bootstrap_attempted = False
LocalBootstrapService._bootstrap_error = None
with patch('app.services.local_bootstrap.settings') as mock_settings:
mock_settings.LOCAL_MODE = True
mock_settings.INSTANCE_ID = None
import asyncio
asyncio.get_event_loop().run_until_complete(LocalBootstrapService.run())
assert LocalBootstrapService._bootstrap_attempted is True
assert LocalBootstrapService._local_tenant_id is None
assert "INSTANCE_ID is required" in LocalBootstrapService._bootstrap_error
def test_bootstrap_status_tracking(self):
"""Verify bootstrap status is correctly tracked."""
# Reset class state
LocalBootstrapService._local_tenant_id = None
LocalBootstrapService._bootstrap_attempted = False
LocalBootstrapService._bootstrap_error = None
status = LocalBootstrapService.get_bootstrap_status()
assert status["attempted"] is False
assert status["success"] is False
assert status["tenant_id"] is None
assert status["error"] is None
class TestMetaEndpointStability:
"""
Tests that /api/v1/meta/instance is stable in all scenarios.
Key assertions:
- Endpoint works before tenant exists
- Endpoint works after tenant bootstrap fails
- Endpoint always returns required fields
"""
def test_meta_endpoint_returns_required_fields(self):
"""Verify meta endpoint always returns required fields."""
# Reset bootstrap state
LocalBootstrapService._local_tenant_id = None
LocalBootstrapService._bootstrap_attempted = False
LocalBootstrapService._bootstrap_error = None
client = TestClient(app)
response = client.get("/api/v1/meta/instance")
assert response.status_code == 200
data = response.json()
# These fields must always be present
assert "local_mode" in data
assert "version" in data
assert "bootstrap_status" in data
# These fields can be null but must be present
assert "instance_id" in data
assert "tenant_id" in data
def test_meta_endpoint_after_failed_bootstrap(self):
"""Verify meta endpoint works even after bootstrap failure."""
# Simulate failed bootstrap
LocalBootstrapService._local_tenant_id = None
LocalBootstrapService._bootstrap_attempted = True
LocalBootstrapService._bootstrap_error = "Test error"
client = TestClient(app)
response = client.get("/api/v1/meta/instance")
assert response.status_code == 200
data = response.json()
# Should still return all fields
assert data["bootstrap_status"]["attempted"] is True
assert data["bootstrap_status"]["success"] is False
assert data["bootstrap_status"]["error"] == "Test error"
def test_meta_endpoint_with_successful_bootstrap(self):
"""Verify meta endpoint reflects successful bootstrap."""
tenant_id = uuid4()
# Simulate successful bootstrap
LocalBootstrapService._local_tenant_id = tenant_id
LocalBootstrapService._bootstrap_attempted = True
LocalBootstrapService._bootstrap_error = None
with patch('app.routes.meta.settings') as mock_settings:
mock_settings.LOCAL_MODE = True
mock_settings.INSTANCE_ID = "test-instance-123"
mock_settings.APP_VERSION = "0.1.0"
client = TestClient(app)
response = client.get("/api/v1/meta/instance")
assert response.status_code == 200
data = response.json()
assert data["local_mode"] is True
assert data["instance_id"] == "test-instance-123"
assert data["tenant_id"] == str(tenant_id)
assert data["bootstrap_status"]["success"] is True
class TestConfigDefaults:
"""
Tests for configuration defaults related to LOCAL_MODE.
"""
def test_all_local_mode_settings_have_safe_defaults(self):
"""Verify all LOCAL_MODE settings have safe defaults that don't break multi-tenant mode."""
settings = Settings()
# Core setting defaults to False (multi-tenant)
assert settings.LOCAL_MODE is False
# Optional settings default to None/False
assert settings.INSTANCE_ID is None
assert settings.HUB_URL is None
assert settings.HUB_API_KEY is None
assert settings.HUB_TELEMETRY_ENABLED is False
# Local tenant domain has a default
assert settings.LOCAL_TENANT_DOMAIN == "local.letsbe.cloud"
# Phase 2: LOCAL_AGENT_KEY defaults to None
assert settings.LOCAL_AGENT_KEY is None
class TestLocalAgentRegistration:
"""
Tests for /api/v1/agents/register-local endpoint (Phase 2).
HTTP Status Semantics:
- 404: Endpoint hidden when LOCAL_MODE=false
- 401: Invalid or missing LOCAL_AGENT_KEY
- 503: Local tenant not bootstrapped
- 201: New agent created
- 200: Existing agent returned (idempotent)
"""
def test_register_local_hidden_when_local_mode_false(self):
"""Verify /register-local returns 404 when LOCAL_MODE=false (security by obscurity)."""
# Reset bootstrap state
LocalBootstrapService._local_tenant_id = None
LocalBootstrapService._bootstrap_attempted = False
LocalBootstrapService._bootstrap_error = None
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
mock_settings = Settings()
mock_settings.LOCAL_MODE = False
mock_get_settings.return_value = mock_settings
client = TestClient(app)
response = client.post(
"/api/v1/agents/register-local",
json={"hostname": "test-agent", "version": "1.0.0"},
headers={"X-Local-Agent-Key": "any-key"},
)
assert response.status_code == 404
def test_register_local_requires_local_agent_key(self):
"""Verify /register-local returns 401 without X-Local-Agent-Key header."""
# Reset bootstrap state
LocalBootstrapService._local_tenant_id = uuid4()
LocalBootstrapService._bootstrap_attempted = True
LocalBootstrapService._bootstrap_error = None
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
mock_settings = Settings()
mock_settings.LOCAL_MODE = True
mock_settings.LOCAL_AGENT_KEY = "test-key-12345"
mock_get_settings.return_value = mock_settings
client = TestClient(app)
# Missing header
response = client.post(
"/api/v1/agents/register-local",
json={"hostname": "test-agent", "version": "1.0.0"},
)
assert response.status_code == 422 # FastAPI validation error for missing header
def test_register_local_rejects_invalid_key(self):
"""Verify /register-local returns 401 with wrong LOCAL_AGENT_KEY."""
# Reset bootstrap state
LocalBootstrapService._local_tenant_id = uuid4()
LocalBootstrapService._bootstrap_attempted = True
LocalBootstrapService._bootstrap_error = None
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
mock_settings = Settings()
mock_settings.LOCAL_MODE = True
mock_settings.LOCAL_AGENT_KEY = "correct-key"
mock_get_settings.return_value = mock_settings
client = TestClient(app)
response = client.post(
"/api/v1/agents/register-local",
json={"hostname": "test-agent", "version": "1.0.0"},
headers={"X-Local-Agent-Key": "wrong-key"},
)
assert response.status_code == 401
def test_register_local_503_when_not_bootstrapped(self):
"""Verify /register-local returns 503 if local tenant not bootstrapped."""
# Bootstrap not complete
LocalBootstrapService._local_tenant_id = None
LocalBootstrapService._bootstrap_attempted = False
LocalBootstrapService._bootstrap_error = None
with patch('app.dependencies.local_agent_auth.get_settings') as mock_get_settings:
mock_settings = Settings()
mock_settings.LOCAL_MODE = True
mock_settings.LOCAL_AGENT_KEY = "test-key"
mock_get_settings.return_value = mock_settings
client = TestClient(app)
response = client.post(
"/api/v1/agents/register-local",
json={"hostname": "test-agent", "version": "1.0.0"},
headers={"X-Local-Agent-Key": "test-key"},
)
assert response.status_code == 503
assert "Retry-After" in response.headers

View File

@@ -0,0 +1,141 @@
"""Tests for the Chatwoot 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.chatwoot import (
CHATWOOT_ENV_PATH,
CHATWOOT_STACK_DIR,
CompositeStep,
build_chatwoot_setup_steps,
create_chatwoot_setup_task,
)
class TestBuildChatwootSetupSteps:
"""Tests for the build_chatwoot_setup_steps function."""
def test_returns_two_steps(self):
"""Verify that build_chatwoot_setup_steps returns exactly 2 steps."""
steps = build_chatwoot_setup_steps(domain="support.example.com")
assert len(steps) == 2
assert all(isinstance(step, CompositeStep) for step in steps)
def test_env_update_payload(self):
"""Verify the ENV_UPDATE step has the correct payload structure."""
domain = "support.example.com"
steps = build_chatwoot_setup_steps(domain=domain)
env_step = steps[0]
assert env_step.type == "ENV_UPDATE"
assert env_step.payload["path"] == CHATWOOT_ENV_PATH
assert env_step.payload["updates"]["FRONTEND_URL"] == f"https://{domain}"
assert env_step.payload["updates"]["BACKEND_URL"] == f"https://{domain}"
def test_docker_reload_payload(self):
"""Verify the DOCKER_RELOAD step has the correct payload structure."""
steps = build_chatwoot_setup_steps(domain="support.example.com")
docker_step = steps[1]
assert docker_step.type == "DOCKER_RELOAD"
assert docker_step.payload["compose_dir"] == CHATWOOT_STACK_DIR
assert docker_step.payload["pull"] is True
def test_domain_url_formatting(self):
"""Verify that domain URLs are properly formatted with https."""
domain = "chat.mycompany.io"
steps = build_chatwoot_setup_steps(domain=domain)
env_step = steps[0]
assert env_step.payload["updates"]["FRONTEND_URL"] == "https://chat.mycompany.io"
assert env_step.payload["updates"]["BACKEND_URL"] == "https://chat.mycompany.io"
@pytest.mark.asyncio
class TestCreateChatwootSetupTask:
"""Tests for the create_chatwoot_setup_task function."""
async def test_persists_composite_task(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that create_chatwoot_setup_task persists a COMPOSITE task."""
task = await create_chatwoot_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=None,
domain="support.example.com",
)
assert task.id is not None
assert task.tenant_id == test_tenant.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_chatwoot_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=None,
domain="support.example.com",
)
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_chatwoot_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=None,
domain="support.example.com",
)
steps = task.payload["steps"]
# First step should be ENV_UPDATE
assert steps[0]["type"] == "ENV_UPDATE"
assert "path" in steps[0]["payload"]
assert "updates" in steps[0]["payload"]
# Second step should be DOCKER_RELOAD
assert steps[1]["type"] == "DOCKER_RELOAD"
assert "compose_dir" in steps[1]["payload"]
assert steps[1]["payload"]["pull"] is True
async def test_task_with_agent_id(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that agent_id is properly assigned when provided."""
agent_id = uuid.uuid4()
# Note: In a real scenario, the agent would need to exist in the DB
# For this test, we're just verifying the task stores the agent_id
task = await create_chatwoot_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=agent_id,
domain="support.example.com",
)
assert task.agent_id == agent_id
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_chatwoot_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=None,
domain="support.example.com",
)
# 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

View File

@@ -0,0 +1,317 @@
"""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_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"
# allowed_domains should be in options, not inputs
assert "options" in payload
assert "allowed_domains" in payload["options"]
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["options"]["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["options"]["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"
# allowed_domains should be in options, not inputs
assert task.payload["options"]["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."""
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

View File

@@ -0,0 +1,230 @@
"""Tests for the Poste.io 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.poste import (
POSTE_STACK_DIR,
build_poste_initial_setup_step,
create_poste_initial_setup_task,
)
class TestBuildPosteInitialSetupStep:
"""Tests for the build_poste_initial_setup_step function."""
def test_returns_playwright_payload(self):
"""Verify that build_poste_initial_setup_step returns correct structure."""
payload = build_poste_initial_setup_step(
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
assert payload["scenario"] == "poste_initial_setup"
assert "inputs" in payload
assert "options" in payload
assert "timeout" in payload
def test_inputs_contain_required_fields(self):
"""Verify that inputs contain base_url and admin_email."""
payload = build_poste_initial_setup_step(
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
inputs = payload["inputs"]
assert inputs["base_url"] == "https://mail.example.com"
assert inputs["admin_email"] == "admin@example.com"
def test_password_included_when_provided(self):
"""Verify that admin_password is included in inputs when provided."""
payload = build_poste_initial_setup_step(
base_url="https://mail.example.com",
admin_email="admin@example.com",
admin_password="secure-password-123",
)
assert payload["inputs"]["admin_password"] == "secure-password-123"
def test_password_omitted_when_none(self):
"""Verify that admin_password is omitted when not provided."""
payload = build_poste_initial_setup_step(
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
assert "admin_password" not in payload["inputs"]
def test_allowed_domains_extracted_from_url(self):
"""Verify that allowed_domains is extracted from base_url."""
payload = build_poste_initial_setup_step(
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
assert payload["options"]["allowed_domains"] == ["mail.example.com"]
def test_allowed_domains_with_port(self):
"""Verify that allowed_domains handles URLs with ports."""
payload = build_poste_initial_setup_step(
base_url="https://mail.example.com:8443",
admin_email="admin@example.com",
)
assert payload["options"]["allowed_domains"] == ["mail.example.com:8443"]
def test_timeout_is_set(self):
"""Verify that timeout is 120 seconds."""
payload = build_poste_initial_setup_step(
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
assert payload["timeout"] == 120
def test_domain_url_with_subdomain(self):
"""Verify correct domain extraction from a subdomain URL."""
payload = build_poste_initial_setup_step(
base_url="https://mail.mycompany.io",
admin_email="postmaster@mycompany.io",
)
assert payload["options"]["allowed_domains"] == ["mail.mycompany.io"]
assert payload["inputs"]["admin_email"] == "postmaster@mycompany.io"
def test_password_with_special_characters(self):
"""Verify that passwords with special characters are handled."""
special_password = "p@$$w0rd!#%^&*()"
payload = build_poste_initial_setup_step(
base_url="https://mail.example.com",
admin_email="admin@example.com",
admin_password=special_password,
)
assert payload["inputs"]["admin_password"] == special_password
@pytest.mark.asyncio
class TestCreatePosteInitialSetupTask:
"""Tests for the create_poste_initial_setup_task function."""
async def test_persists_playwright_task(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that create_poste_initial_setup_task persists a PLAYWRIGHT task."""
agent_id = uuid.uuid4()
task = await create_poste_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=agent_id,
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
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_poste_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
assert task.payload["scenario"] == "poste_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_poste_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
base_url="https://mail.example.com",
admin_email="postmaster@example.com",
)
inputs = task.payload["inputs"]
assert inputs["base_url"] == "https://mail.example.com"
assert inputs["admin_email"] == "postmaster@example.com"
async def test_task_with_password(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that admin_password is included when provided."""
task = await create_poste_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
base_url="https://mail.example.com",
admin_email="admin@example.com",
admin_password="test-password-123",
)
assert task.payload["inputs"]["admin_password"] == "test-password-123"
async def test_task_without_password(self, db: AsyncSession, test_tenant: Tenant):
"""Verify that admin_password is omitted when not provided."""
task = await create_poste_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
assert "admin_password" not in task.payload["inputs"]
async def test_task_options_contain_allowed_domains(
self, db: AsyncSession, test_tenant: Tenant
):
"""Verify that task options include allowed_domains."""
task = await create_poste_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
assert task.payload["options"]["allowed_domains"] == ["mail.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_poste_initial_setup_task(
db=db,
tenant_id=test_tenant.id,
agent_id=uuid.uuid4(),
base_url="https://mail.example.com",
admin_email="admin@example.com",
)
# 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
class TestPosteConstants:
"""Tests for module constants."""
def test_poste_stack_dir(self):
"""Verify POSTE_STACK_DIR is set correctly."""
assert POSTE_STACK_DIR == "/opt/letsbe/stacks/poste"