Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user