Include full contents of all nested repositories

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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