"""Per-stack file-based secrets & configs (compose) — local host. CRUD over the secret/config files stored in the stack directory, plus attach/detach which rewrite the stack's compose file to reference (or stop referencing) a secret/config from a service. A redeploy is required for changes to take effect on running containers. """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from sqlmodel import Session from auth import require_admin from database import get_session from models.user import User from services import audit_service, secret_service from services.compose_edit_service import EditError from services.secret_service import SecretError router = APIRouter(prefix="/api/stacks/{stack_id}/secrets", tags=["secrets"]) class SecretWrite(BaseModel): kind: str = "secret" # "secret" | "config" name: str content: str class AttachBody(BaseModel): kind: str = "secret" name: str service: str target: str | None = None # required for configs (mount path) class DetachBody(BaseModel): kind: str = "secret" name: str service: str def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _guard(fn, *args, **kwargs): try: return fn(*args, **kwargs) except (SecretError, EditError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc # --- These functions are shared verbatim by the agent (see agent_app.py). --- def list_secrets(stack_id: str) -> list[dict]: return secret_service.list_all(stack_id) def write_secret(stack_id: str, body: SecretWrite) -> dict: return _guard(secret_service.write_secret, stack_id, body.kind, body.name, body.content) def delete_secret(stack_id: str, kind: str, name: str) -> dict: _guard(secret_service.delete_secret, stack_id, kind, name) return {"ok": True} def attach_secret(stack_id: str, body: AttachBody) -> dict: if not secret_service.exists(stack_id, body.kind, body.name): raise HTTPException(status_code=404, detail="secret not found") new_yaml = _guard(secret_service.attach, stack_id, body.kind, body.name, body.service, body.target) return {"ok": True, "yaml": new_yaml} def detach_secret(stack_id: str, body: DetachBody) -> dict: new_yaml = _guard(secret_service.detach, stack_id, body.kind, body.name, body.service) return {"ok": True, "yaml": new_yaml} # --- HTTP endpoints (admin only; secrets are sensitive) --- @router.get("") def http_list(stack_id: str, _user: User = Depends(require_admin)) -> list[dict]: return list_secrets(stack_id) @router.put("") def http_write( stack_id: str, body: SecretWrite, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: result = write_secret(stack_id, body) audit_service.record( session, user=user.username, action="secret.write", target=f"{stack_id}/{body.kind}/{body.name}", ip=_ip(request), ) return result @router.delete("/{kind}/{name}") def http_delete( stack_id: str, kind: str, name: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: result = delete_secret(stack_id, kind, name) audit_service.record( session, user=user.username, action="secret.delete", target=f"{stack_id}/{kind}/{name}", ip=_ip(request), ) return result @router.post("/attach") def http_attach( stack_id: str, body: AttachBody, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: result = attach_secret(stack_id, body) audit_service.record( session, user=user.username, action="secret.attach", target=f"{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request), ) return result @router.post("/detach") def http_detach( stack_id: str, body: DetachBody, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: result = detach_secret(stack_id, body) audit_service.record( session, user=user.username, action="secret.detach", target=f"{stack_id}/{body.kind}/{body.name}->{body.service}", ip=_ip(request), ) return result