Shows an amber "Update" pill next to a stack's status (and highlights the
inline Update button) when any of the stack's images has a newer digest in
the registry. Reuses the existing background image-update check — a new
update_service.stacks_update_summary() reads the cached digests in a single
container sweep (no extra registry calls), exposed as GET /api/stacks/updates
and proxied per agent at GET /api/agents/{id}/stacks/updates. The Stacks page
and each remote-host section poll it every 60s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
455 lines
16 KiB
Python
455 lines
16 KiB
Python
"""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, 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) -> dict:
|
|
"""Build a list-row summary.
|
|
|
|
Pass ``summaries`` (from :func:`compose_service.stack_status_summaries`) to
|
|
serve the whole stacks list from a single Docker call. Without it (single
|
|
create/update/clone responses), fall back to one direct query for this stack.
|
|
"""
|
|
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 compose_service.is_busy(stack.id):
|
|
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 = {}
|
|
return [_stack_summary(s, summaries) 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),
|
|
"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
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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)
|