"""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.user import User from services import audit_service, compose_service, notify_service, stats_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) -> dict: try: containers = compose_service.containers_for_stack(stack.id) status = compose_service.compute_status(stack.id) except DockerError: containers = [] status = "unknown" return { "id": stack.id, "name": stack.name, "description": stack.description, "status": status, "service_count": len(containers), "running_count": sum(1 for c in containers if c.state == "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() return [_stack_summary(s) 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("/{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: containers = [asdict(c) for c in compose_service.containers_for_stack(stack_id)] status = compose_service.compute_status(stack_id) except DockerError as exc: containers = [] status = "unknown" return { "id": stack.id, "name": stack.name, "description": stack.description, "status": status, "yaml": compose_service.read_compose(stack_id), "env": compose_service.read_env(stack_id), "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) result = await action_fn(stack_id) 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)): return await _lifecycle(compose_service.pull, "pull", stack_id, request, session, user) @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)): return await _lifecycle(compose_service.update, "update", stack_id, request, session, user) @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), _user: User = Depends(get_current_user), ): import io import tarfile import tempfile stack = _get_stack_or_404(session, stack_id) 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