Files
Unraid-AppOps-Gateway-Public/backend/app/api/routes_deployments.py
T

157 lines
4.8 KiB
Python

"""Operator API for Deployment Control Plane V1.
All mutations accept a typed plan and an idempotency key. There is no endpoint
for command execution, Compose upload, arbitrary host paths or raw secrets.
"""
from __future__ import annotations
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlmodel import Session
from ..core.security import require_operator_request
from ..db.session import get_session
from ..schemas.deployment import DeploymentPlanV1
from ..schemas.forge_bootstrap import ForgeBootstrapPreflightV1
from ..services.deployment_broker import DeploymentBrokerClient
from ..services.deployment_control_plane import (
DeploymentControlError,
DeploymentControlPlane,
)
def _require_deployment_operator(request: Request) -> str:
return require_operator_request(request)
router = APIRouter(
prefix="/api/deployments",
tags=["deployments"],
dependencies=[Depends(_require_deployment_operator)],
)
class DeploymentRequestV1(BaseModel):
model_config = ConfigDict(extra="forbid")
plan: DeploymentPlanV1
operation: Literal["preflight", "apply", "rollback"] = "preflight"
class WorkMeshPromotionPreflightRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
revision: str = Field(pattern=r"^[0-9a-f]{40}$")
DeploymentSession = Annotated[Session, Depends(get_session)]
DeploymentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")]
DeploymentLimit = Annotated[int, Query(ge=1, le=250)]
def _idempotency_header(value: str | None) -> str:
if not value:
raise HTTPException(status_code=400, detail="Idempotency-Key is required for deployment operations.")
return value.strip()
@router.post("")
def request_deployment(
payload: DeploymentRequestV1,
session: DeploymentSession,
idempotency_key: DeploymentIdempotencyKey = None,
):
try:
return DeploymentControlPlane().execute(
session,
plan=payload.plan,
idempotency_key=_idempotency_header(idempotency_key),
operation=payload.operation,
)
except DeploymentControlError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/workmesh/promotions/preflight")
def preflight_workmesh_promotion(
payload: WorkMeshPromotionPreflightRequest,
session: DeploymentSession,
idempotency_key: DeploymentIdempotencyKey = None,
):
from ..services.workmesh_promotion import (
WorkMeshPromotionError,
WorkMeshPromotionService,
)
try:
return WorkMeshPromotionService().preflight(
session,
revision=payload.revision,
idempotency_key=_idempotency_header(idempotency_key),
)
except WorkMeshPromotionError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/forge-bootstrap/preflight")
def preflight_forge_bootstrap(
payload: ForgeBootstrapPreflightV1,
session: DeploymentSession,
idempotency_key: DeploymentIdempotencyKey = None,
):
from ..services.forge_bootstrap_preflight import (
ForgeBootstrapPreflightError,
ForgeBootstrapPreflightService,
)
try:
return ForgeBootstrapPreflightService().preflight(
session,
payload,
idempotency_key=_idempotency_header(idempotency_key),
)
except ForgeBootstrapPreflightError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.get("/workmesh/promotions/{promotion_id}")
def get_workmesh_promotion(promotion_id: str, session: DeploymentSession):
from ..services.workmesh_promotion import WorkMeshPromotionService
result = WorkMeshPromotionService().refresh(session, promotion_id)
if result is None:
raise HTTPException(status_code=404, detail="WorkMesh promotion not found.")
return result
@router.get("/workmesh/promotions")
def list_workmesh_promotions(
session: DeploymentSession,
limit: DeploymentLimit = 20,
):
from ..services.workmesh_promotion import WorkMeshPromotionService
return {"items": WorkMeshPromotionService.list_promotions(session, limit=limit)}
@router.get("")
def list_deployments(session: DeploymentSession, limit: DeploymentLimit = 100):
return {"items": DeploymentControlPlane().list_jobs(session, limit=limit)}
@router.get("/delivery-status")
def get_delivery_status():
"""Expose the controller's bounded, secret-free runnerless delivery view."""
return DeploymentBrokerClient().delivery_status()
@router.get("/{job_id}")
def get_deployment(job_id: str, session: DeploymentSession):
try:
return DeploymentControlPlane().refresh(session, job_id)
except DeploymentControlError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc