"""Stack CRUD + lifecycle endpoints.""" from __future__ import annotations import os from dataclasses import asdict from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import FileResponse from sqlmodel import Session, select from auth import get_current_user, require_admin from database import get_session from docker_client import DockerError from models.stack import ( ConvertRequest, ConvertResponse, Stack, StackCloneRequest, StackCreate, StackUpdate, ) from models.setting import ( EVENT_PULL_FAILED, EVENT_STACK_ERROR, EVENT_STACK_START, EVENT_STACK_STOP, ) from models.auto_update import AutoUpdateRead, AutoUpdateWrite from models.user import User from services import ( audit_service, auto_update_service, compose_service, notify_service, stack_lock_service, stats_service, update_service, ) from services.convert_service import convert_docker_run router = APIRouter(prefix="/api/stacks", tags=["stacks"]) # --------------------------------------------------------------------------- # # helpers # --------------------------------------------------------------------------- # def _client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" def sync_discovered_stacks(session: Session) -> None: """Register any on-disk stacks not yet in the database.""" known = {s.id for s in session.exec(select(Stack)).all()} for stack_id in compose_service.discover_stacks(): if stack_id not in known: stack = Stack(id=stack_id, name=stack_id) session.add(stack) session.commit() def _get_stack_or_404(session: Session, stack_id: str) -> Stack: stack = session.get(Stack, stack_id) if not stack: raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found") return stack def _stack_summary( stack: Stack, summaries: dict | None = None, busy: dict[str, str] | None = None ) -> dict: """Build a list-row summary. Pass ``summaries`` (from :func:`compose_service.stack_status_summaries`) and ``busy`` (from :func:`stack_lock_service.active`) to serve the whole stacks list from one Docker call and one query. Without them (single create/update/clone responses), fall back to one direct query for this stack. """ busy = busy or {} if summaries is None: try: containers = compose_service.containers_for_stack(stack.id) total = len(containers) running = sum(1 for c in containers if c.state == "running") status = compose_service.compute_status(stack.id, containers) except DockerError: total = running = 0 status = "unknown" else: info = summaries.get(stack.id) total = info["total"] if info else 0 running = info["running"] if info else 0 if stack.id in busy: status = "updating" else: status = info["status"] if info else "stopped" return { "id": stack.id, "name": stack.name, "description": stack.description, "status": status, "service_count": total, "running_count": running, "created_at": stack.created_at, "updated_at": stack.updated_at, } # --------------------------------------------------------------------------- # # CRUD # --------------------------------------------------------------------------- # @router.get("") def list_stacks( session: Session = Depends(get_session), _user: User = Depends(get_current_user), ) -> list[dict]: sync_discovered_stacks(session) stacks = session.exec(select(Stack)).all() try: summaries = compose_service.stack_status_summaries() except DockerError: summaries = {} busy = stack_lock_service.active(session) return [_stack_summary(s, summaries, busy) for s in stacks] @router.post("", status_code=201) def create_stack( body: StackCreate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: stack_id = compose_service.slugify(body.name) if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)): raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists") compose_service.write_compose(stack_id, body.yaml or "services:\n") if body.env: compose_service.write_env(stack_id, body.env) stack = Stack(id=stack_id, name=body.name, description=body.description) session.add(stack) session.commit() session.refresh(stack) audit_service.record( session, user=user.username, action="stack.create", target=stack_id, ip=_client_ip(request), ) return _stack_summary(stack) @router.get("/stats") def stacks_stats(_user: User = Depends(get_current_user)) -> dict: """Live CPU (cores) and memory usage per stack, with assigned limits.""" return stats_service.stack_stats() @router.get("/updates") def stacks_updates(_user: User = Depends(get_current_user)) -> dict: """Per-stack image-update availability, read from the cached registry digests (no live registry calls — safe for the list to poll).""" return update_service.stacks_update_summary() @router.get("/{stack_id}") def get_stack( stack_id: str, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> dict: stack = _get_stack_or_404(session, stack_id) try: raw = compose_service.containers_for_stack(stack_id) containers = [asdict(c) for c in raw] status = compose_service.compute_status(stack_id, raw) except DockerError: containers = [] status = "unknown" return { "id": stack.id, "name": stack.name, "description": stack.description, "status": status, "yaml": compose_service.read_compose(stack_id), # The .env is where credentials live by convention, so it is withheld # from the read-only role — same reasoning as the admin-only file # browser. Non-admins still get status, services and the compose file. "env": compose_service.read_env(stack_id) if user.role == "admin" else "", "containers": containers, "created_at": stack.created_at, "updated_at": stack.updated_at, } @router.put("/{stack_id}") def update_stack( stack_id: str, body: StackUpdate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: stack = _get_stack_or_404(session, stack_id) if body.yaml is not None: compose_service.write_compose(stack_id, body.yaml) if body.env is not None: compose_service.write_env(stack_id, body.env) if body.name is not None: stack.name = body.name if body.description is not None: stack.description = body.description stack.updated_at = compose_service.now() session.add(stack) session.commit() session.refresh(stack) audit_service.record( session, user=user.username, action="stack.update", target=stack_id, ip=_client_ip(request), ) return _stack_summary(stack) @router.delete("/{stack_id}") async def delete_stack( stack_id: str, request: Request, delete_files: bool = Query(True), session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: stack = _get_stack_or_404(session, stack_id) try: await compose_service.down(stack_id) except Exception: # noqa: BLE001 - best-effort teardown pass if delete_files: compose_service.delete_stack_files(stack_id) session.delete(stack) session.commit() audit_service.record( session, user=user.username, action="stack.delete", target=stack_id, detail=f"delete_files={delete_files}", ip=_client_ip(request), ) return {"ok": True} @router.post("/{stack_id}/clone") def clone_stack( stack_id: str, body: StackCloneRequest, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: _get_stack_or_404(session, stack_id) new_id = compose_service.slugify(body.name) if session.get(Stack, new_id): raise HTTPException(status_code=409, detail=f"Stack '{new_id}' already exists") try: compose_service.clone_stack_files(stack_id, new_id) except compose_service.StackFileError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc stack = Stack(id=new_id, name=body.name) session.add(stack) session.commit() session.refresh(stack) audit_service.record( session, user=user.username, action="stack.clone", target=new_id, detail=f"from {stack_id}", ip=_client_ip(request), ) return _stack_summary(stack) # --------------------------------------------------------------------------- # # lifecycle # --------------------------------------------------------------------------- # # which lifecycle actions emit a notification on success _START_ACTIONS = {"start", "restart", "update"} _STOP_ACTIONS = {"stop", "down"} async def _notify_lifecycle(action_name: str, stack_id: str, ok: bool, detail: str, session) -> None: try: if not ok: event = EVENT_PULL_FAILED if action_name in ("pull", "update") else EVENT_STACK_ERROR await notify_service.notify( event, f"Stack '{stack_id}' {action_name} failed", detail or f"compose {action_name} returned a non-zero exit code.", session, ) elif action_name in _START_ACTIONS: await notify_service.notify( EVENT_STACK_START, f"Stack '{stack_id}' started", f"compose {action_name} completed successfully.", session, ) elif action_name in _STOP_ACTIONS: await notify_service.notify( EVENT_STACK_STOP, f"Stack '{stack_id}' stopped", f"compose {action_name} completed successfully.", session, ) except Exception: # noqa: BLE001 - notifications are best-effort pass async def _lifecycle(action_fn, action_name, stack_id, request, session, user): _get_stack_or_404(session, stack_id) # One compose operation per stack. Without this two tabs (or auto-update # landing on a stack somebody just clicked) both run pull + up -d against # the same project and race over recreating containers. try: with stack_lock_service.hold(session, stack_id, action_name, user.username): result = await action_fn(stack_id) except stack_lock_service.StackBusy as exc: raise HTTPException( status_code=409, detail=f"Stack '{stack_id}' is busy: {exc.action} in progress", ) from exc audit_service.record( session, user=user.username, action=f"stack.{action_name}", target=stack_id, detail=f"rc={result.get('returncode')}", ip=_client_ip(request), ) ok = result.get("returncode") in (0, None) stderr = result.get("stderr", "").strip()[-2000:] await _notify_lifecycle(action_name, stack_id, ok, stderr, session) if not ok: raise HTTPException( status_code=500, detail={ "error": f"compose {action_name} failed", "detail": stderr, }, ) return result @router.post("/{stack_id}/start") async def start_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): return await _lifecycle(compose_service.up, "start", stack_id, request, session, user) @router.post("/{stack_id}/stop") async def stop_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): return await _lifecycle(compose_service.stop, "stop", stack_id, request, session, user) @router.post("/{stack_id}/restart") async def restart_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): return await _lifecycle(compose_service.restart, "restart", stack_id, request, session, user) @router.post("/{stack_id}/pull") async def pull_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): result = await _lifecycle(compose_service.pull, "pull", stack_id, request, session, user) update_service.refresh_stack_local(stack_id) return result @router.post("/{stack_id}/update") async def update_stack_images(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): result = await _lifecycle(compose_service.update, "update", stack_id, request, session, user) update_service.refresh_stack_local(stack_id) return result @router.post("/{stack_id}/down") async def down_stack(stack_id: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin)): return await _lifecycle(compose_service.down, "down", stack_id, request, session, user) # --------------------------------------------------------------------------- # # logs / export / convert # --------------------------------------------------------------------------- # @router.get("/{stack_id}/logs") async def stack_logs( stack_id: str, tail: int = Query(200, le=2000), session: Session = Depends(get_session), _user: User = Depends(get_current_user), ) -> dict: _get_stack_or_404(session, stack_id) result = await compose_service.logs(stack_id, tail=tail) return {"logs": result.get("stdout", "") + result.get("stderr", "")} @router.get("/{stack_id}/services/{service}/logs") async def service_logs( stack_id: str, service: str, tail: int = Query(200, le=2000), session: Session = Depends(get_session), _user: User = Depends(get_current_user), ) -> dict: _get_stack_or_404(session, stack_id) result = await compose_service.logs(stack_id, service=service, tail=tail) return {"logs": result.get("stdout", "") + result.get("stderr", "")} @router.get("/{stack_id}/export") def export_stack( stack_id: str, session: Session = Depends(get_session), _admin: User = Depends(require_admin), ): """Download the whole stack folder as a tarball. Admin only: the archive contains the ``.env`` and every ``.secrets/*`` file verbatim.""" import tarfile import tempfile _get_stack_or_404(session, stack_id) # 404s if unknown directory = compose_service.stack_dir(stack_id) if not os.path.isdir(directory): raise HTTPException(status_code=404, detail="Stack directory missing") tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz") with tarfile.open(tmp.name, "w:gz") as tar: tar.add(directory, arcname=stack_id) date = compose_service.now().strftime("%Y%m%d") return FileResponse( tmp.name, media_type="application/gzip", filename=f"stack-{stack_id}-{date}.tar.gz", ) @router.post("/convert", response_model=ConvertResponse) def convert( body: ConvertRequest, _user: User = Depends(get_current_user), ) -> ConvertResponse: try: return ConvertResponse(yaml=convert_docker_run(body.command)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc # --------------------------------------------------------------------------- # # Auto-update policy (Watchtower-style) — local stacks # --------------------------------------------------------------------------- # @router.get("/{stack_id}/auto-update", response_model=AutoUpdateRead) def get_auto_update( stack_id: str, session: Session = Depends(get_session), _user: User = Depends(get_current_user), ) -> dict: policy = auto_update_service.get_policy(session, stack_id) return auto_update_service.to_read(session, policy, stack_id) @router.put("/{stack_id}/auto-update", response_model=AutoUpdateRead) def set_auto_update( stack_id: str, body: AutoUpdateWrite, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: policy = auto_update_service.upsert_policy(session, stack_id, body.enabled, body.redeploy) audit_service.record( session, user=user.username, action="stack.auto_update", target=stack_id, detail=f"enabled={body.enabled} redeploy={body.redeploy}", ip=_client_ip(request), ) return auto_update_service.to_read(session, policy, stack_id) @router.post("/{stack_id}/auto-update/run", response_model=AutoUpdateRead) async def run_auto_update( stack_id: str, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: policy = auto_update_service.get_policy(session, stack_id) if policy is None: raise HTTPException(status_code=404, detail="No auto-update policy for this stack") await auto_update_service.run_policy(session, policy) session.refresh(policy) return auto_update_service.to_read(session, policy, stack_id)