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

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