Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
letsbe-orchestrator/app/__init__.py
Normal file
1
letsbe-orchestrator/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# LetsBe Cloud Orchestrator
|
||||
104
letsbe-orchestrator/app/config.py
Normal file
104
letsbe-orchestrator/app/config.py
Normal 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()
|
||||
52
letsbe-orchestrator/app/db.py
Normal file
52
letsbe-orchestrator/app/db.py
Normal 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)]
|
||||
19
letsbe-orchestrator/app/dependencies/__init__.py
Normal file
19
letsbe-orchestrator/app/dependencies/__init__.py
Normal 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",
|
||||
]
|
||||
33
letsbe-orchestrator/app/dependencies/admin_auth.py
Normal file
33
letsbe-orchestrator/app/dependencies/admin_auth.py
Normal 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)
|
||||
65
letsbe-orchestrator/app/dependencies/auth.py
Normal file
65
letsbe-orchestrator/app/dependencies/auth.py
Normal 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)]
|
||||
73
letsbe-orchestrator/app/dependencies/dashboard_auth.py
Normal file
73
letsbe-orchestrator/app/dependencies/dashboard_auth.py
Normal 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)]
|
||||
49
letsbe-orchestrator/app/dependencies/local_agent_auth.py
Normal file
49
letsbe-orchestrator/app/dependencies/local_agent_auth.py
Normal 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"},
|
||||
)
|
||||
163
letsbe-orchestrator/app/main.py
Normal file
163
letsbe-orchestrator/app/main.py
Normal 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",
|
||||
}
|
||||
21
letsbe-orchestrator/app/models/__init__.py
Normal file
21
letsbe-orchestrator/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
92
letsbe-orchestrator/app/models/agent.py
Normal file
92
letsbe-orchestrator/app/models/agent.py
Normal 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})>"
|
||||
44
letsbe-orchestrator/app/models/base.py
Normal file
44
letsbe-orchestrator/app/models/base.py
Normal 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,
|
||||
)
|
||||
72
letsbe-orchestrator/app/models/event.py
Normal file
72
letsbe-orchestrator/app/models/event.py
Normal 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})>"
|
||||
101
letsbe-orchestrator/app/models/registration_token.py
Normal file
101
letsbe-orchestrator/app/models/registration_token.py
Normal 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
|
||||
59
letsbe-orchestrator/app/models/server.py
Normal file
59
letsbe-orchestrator/app/models/server.py
Normal 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})>"
|
||||
85
letsbe-orchestrator/app/models/task.py
Normal file
85
letsbe-orchestrator/app/models/task.py
Normal 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})>"
|
||||
67
letsbe-orchestrator/app/models/tenant.py
Normal file
67
letsbe-orchestrator/app/models/tenant.py
Normal 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})>"
|
||||
74
letsbe-orchestrator/app/playbooks/__init__.py
Normal file
74
letsbe-orchestrator/app/playbooks/__init__.py
Normal 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",
|
||||
]
|
||||
207
letsbe-orchestrator/app/playbooks/calcom.py
Normal file
207
letsbe-orchestrator/app/playbooks/calcom.py
Normal 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
|
||||
207
letsbe-orchestrator/app/playbooks/chatwoot.py
Normal file
207
letsbe-orchestrator/app/playbooks/chatwoot.py
Normal 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
|
||||
214
letsbe-orchestrator/app/playbooks/keycloak.py
Normal file
214
letsbe-orchestrator/app/playbooks/keycloak.py
Normal 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
|
||||
208
letsbe-orchestrator/app/playbooks/n8n.py
Normal file
208
letsbe-orchestrator/app/playbooks/n8n.py
Normal 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
|
||||
192
letsbe-orchestrator/app/playbooks/nextcloud.py
Normal file
192
letsbe-orchestrator/app/playbooks/nextcloud.py
Normal 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
|
||||
105
letsbe-orchestrator/app/playbooks/portainer.py
Normal file
105
letsbe-orchestrator/app/playbooks/portainer.py
Normal 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
|
||||
111
letsbe-orchestrator/app/playbooks/poste.py
Normal file
111
letsbe-orchestrator/app/playbooks/poste.py
Normal 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
|
||||
205
letsbe-orchestrator/app/playbooks/umami.py
Normal file
205
letsbe-orchestrator/app/playbooks/umami.py
Normal 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
|
||||
194
letsbe-orchestrator/app/playbooks/uptime_kuma.py
Normal file
194
letsbe-orchestrator/app/playbooks/uptime_kuma.py
Normal 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
|
||||
121
letsbe-orchestrator/app/playbooks/vaultwarden.py
Normal file
121
letsbe-orchestrator/app/playbooks/vaultwarden.py
Normal 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
|
||||
25
letsbe-orchestrator/app/routes/__init__.py
Normal file
25
letsbe-orchestrator/app/routes/__init__.py
Normal 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",
|
||||
]
|
||||
529
letsbe-orchestrator/app/routes/agents.py
Normal file
529
letsbe-orchestrator/app/routes/agents.py
Normal 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")
|
||||
158
letsbe-orchestrator/app/routes/env.py
Normal file
158
letsbe-orchestrator/app/routes/env.py
Normal 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
|
||||
77
letsbe-orchestrator/app/routes/events.py
Normal file
77
letsbe-orchestrator/app/routes/events.py
Normal 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
|
||||
94
letsbe-orchestrator/app/routes/files.py
Normal file
94
letsbe-orchestrator/app/routes/files.py
Normal 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
|
||||
21
letsbe-orchestrator/app/routes/health.py
Normal file
21
letsbe-orchestrator/app/routes/health.py
Normal 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,
|
||||
)
|
||||
35
letsbe-orchestrator/app/routes/meta.py
Normal file
35
letsbe-orchestrator/app/routes/meta.py
Normal 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(),
|
||||
)
|
||||
1530
letsbe-orchestrator/app/routes/playbooks.py
Normal file
1530
letsbe-orchestrator/app/routes/playbooks.py
Normal file
File diff suppressed because it is too large
Load Diff
214
letsbe-orchestrator/app/routes/registration_tokens.py
Normal file
214
letsbe-orchestrator/app/routes/registration_tokens.py
Normal 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()
|
||||
283
letsbe-orchestrator/app/routes/tasks.py
Normal file
283
letsbe-orchestrator/app/routes/tasks.py
Normal 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)
|
||||
185
letsbe-orchestrator/app/routes/tenants.py
Normal file
185
letsbe-orchestrator/app/routes/tenants.py
Normal 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()
|
||||
60
letsbe-orchestrator/app/schemas/__init__.py
Normal file
60
letsbe-orchestrator/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
111
letsbe-orchestrator/app/schemas/agent.py
Normal file
111
letsbe-orchestrator/app/schemas/agent.py
Normal 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
|
||||
39
letsbe-orchestrator/app/schemas/common.py
Normal file
39
letsbe-orchestrator/app/schemas/common.py
Normal 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
|
||||
32
letsbe-orchestrator/app/schemas/env.py
Normal file
32
letsbe-orchestrator/app/schemas/env.py
Normal 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"
|
||||
)
|
||||
37
letsbe-orchestrator/app/schemas/event.py
Normal file
37
letsbe-orchestrator/app/schemas/event.py
Normal 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
|
||||
20
letsbe-orchestrator/app/schemas/file.py
Normal file
20
letsbe-orchestrator/app/schemas/file.py
Normal 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)",
|
||||
)
|
||||
63
letsbe-orchestrator/app/schemas/registration_token.py
Normal file
63
letsbe-orchestrator/app/schemas/registration_token.py
Normal 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
|
||||
70
letsbe-orchestrator/app/schemas/task.py
Normal file
70
letsbe-orchestrator/app/schemas/task.py
Normal 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
|
||||
73
letsbe-orchestrator/app/schemas/tasks_extended.py
Normal file
73
letsbe-orchestrator/app/schemas/tasks_extended.py
Normal 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"
|
||||
)
|
||||
45
letsbe-orchestrator/app/schemas/tenant.py
Normal file
45
letsbe-orchestrator/app/schemas/tenant.py
Normal 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,
|
||||
)
|
||||
5
letsbe-orchestrator/app/services/__init__.py
Normal file
5
letsbe-orchestrator/app/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Service layer for the Orchestrator."""
|
||||
|
||||
from app.services.local_bootstrap import LocalBootstrapService
|
||||
|
||||
__all__ = ["LocalBootstrapService"]
|
||||
270
letsbe-orchestrator/app/services/hub_telemetry.py
Normal file
270
letsbe-orchestrator/app/services/hub_telemetry.py
Normal 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,
|
||||
}
|
||||
167
letsbe-orchestrator/app/services/local_bootstrap.py
Normal file
167
letsbe-orchestrator/app/services/local_bootstrap.py
Normal 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
|
||||
Reference in New Issue
Block a user