A backup holds the stack lock for up to twelve hours — a large volume
legitimately takes that long — and stops the stack's containers for a
consistent snapshot. One that died without unwinding therefore left the
stack stopped and refusing every start, stop, restart and deploy with
"Stack 'npmplus' is busy: backup in progress", with nothing to click
until the TTL ran out.
Backups and restores now register themselves in stack_job_service while
they run, with their phase and the helper containers they stream
through. Cancelling sets a flag and removes those containers, which is
what actually ends the long read: the backup then unwinds through its
own finally — the half-written archive goes, the containers it stopped
are started again, the lock is released. Checks sit between two files
and two volumes, never inside one. A restore is not cancellable at all;
it is halfway through writing a stack's data back.
POST /api/stacks/{id}/unlock does that and waits for it. A backup lock
with no job behind it is a leftover, so it is released on one click; a
deploy lock takes an explicit force, because compose keeps running past
its request. GET /api/stacks/{id}/lock and the stack detail response say
what holds the stack and since when, and the stack page turns that into
"Stop backup" / "Release lock".
Startup now clears every lock rather than only expired ones: after a
restart nothing of ours is in flight, the same reasoning the leftover
helper containers already follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
789 lines
28 KiB
Python
789 lines
28 KiB
Python
"""Stack CRUD + lifecycle endpoints."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from dataclasses import asdict
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
|
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,
|
|
icon_service,
|
|
logo_service,
|
|
notify_service,
|
|
stack_job_service,
|
|
stack_lock_service,
|
|
stats_service,
|
|
update_service,
|
|
)
|
|
from services.convert_service import convert_docker_run
|
|
|
|
logger = logging.getLogger("stackpilot.stacks")
|
|
|
|
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 _auto_icon(stack: Stack) -> str | None:
|
|
"""The logo a stack gets when nothing is configured, as an icon value."""
|
|
if stack.icon:
|
|
return None
|
|
slug = logo_service.auto_slug(stack.id, stack.name)
|
|
return f"logo:{slug}" if slug else None
|
|
|
|
|
|
def _lock_view(lock: dict | None, stack_id: str) -> dict | None:
|
|
"""The lock plus what can be done about it.
|
|
|
|
``cancellable`` is true only while the operation is actually running in
|
|
this process and can stop halfway (a backup can, a restore must not).
|
|
Everything else can still be force-released — that drops the guard without
|
|
stopping anything, which is the last resort for a lock whose operation is
|
|
long gone.
|
|
"""
|
|
if not lock:
|
|
return None
|
|
job = stack_job_service.get(stack_id)
|
|
return {
|
|
**lock,
|
|
"cancellable": bool(job and job.cancellable and not job.cancelled),
|
|
"cancelling": bool(job and job.cancelled),
|
|
# A backup or restore always registers itself, so a lock of theirs
|
|
# with no job behind it is a leftover: the run is over and releasing
|
|
# the row stops nothing.
|
|
"stale": _is_orphaned(lock["action"], job),
|
|
"phase": job.phase if job else "",
|
|
}
|
|
|
|
|
|
def _is_orphaned(action: str, job) -> bool:
|
|
return job is None and action in stack_job_service.TRACKED
|
|
|
|
|
|
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,
|
|
"icon": stack.icon,
|
|
# With no explicit choice, the app logo the name resolves to (the
|
|
# frontend falls back to a name-derived glyph when this is null).
|
|
"auto_icon": _auto_icon(stack),
|
|
"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")
|
|
try:
|
|
icon = icon_service.normalize_choice(body.icon or "")
|
|
except icon_service.IconError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
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, icon=icon
|
|
)
|
|
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("/icons/search")
|
|
def search_app_logos(
|
|
q: str = Query("", max_length=64),
|
|
limit: int = Query(60, ge=1, le=200),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
"""Search the app-logo catalog (Jellyfin, Postgres, Gitea, …).
|
|
|
|
``ready`` is false when the catalog has not been downloaded yet — a box with
|
|
no outbound internet, or the very first minute after a fresh install. The
|
|
picker says so instead of looking empty and broken.
|
|
"""
|
|
return {
|
|
"ready": logo_service.load_catalog() is not None,
|
|
"icons": logo_service.search(q, limit),
|
|
}
|
|
|
|
|
|
@router.get("/icons/logo/{slug}")
|
|
async def get_app_logo(
|
|
slug: str,
|
|
_user: User = Depends(get_current_user),
|
|
) -> FileResponse:
|
|
"""One catalog logo by slug, for the picker's result grid."""
|
|
path = await logo_service.ensure_logo(slug)
|
|
if not path:
|
|
raise HTTPException(status_code=404, detail=f"No logo for '{slug}'")
|
|
return _icon_response(path, "image/png", f"{slug}.png")
|
|
|
|
|
|
@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"
|
|
# An operation in flight outranks whatever the containers currently say.
|
|
lock = stack_lock_service.info(session, stack_id)
|
|
if lock:
|
|
status = "updating"
|
|
return {
|
|
"id": stack.id,
|
|
"name": stack.name,
|
|
"description": stack.description,
|
|
"icon": stack.icon,
|
|
"auto_icon": _auto_icon(stack),
|
|
"status": status,
|
|
# What is holding the stack, so the page can offer to stop it instead
|
|
# of only showing a spinner that never ends.
|
|
"lock": _lock_view(lock, stack_id),
|
|
"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
|
|
if body.icon is not None:
|
|
try:
|
|
icon = icon_service.normalize_choice(body.icon)
|
|
except icon_service.IconError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
# Switching to a built-in icon (or back to automatic) makes any
|
|
# uploaded image dead weight, so it goes with the choice.
|
|
if icon_service.custom_ext(stack.icon) and icon != stack.icon:
|
|
icon_service.remove(stack_id)
|
|
stack.icon = icon
|
|
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)
|
|
icon_service.remove(stack_id)
|
|
logo_service.forget(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:
|
|
source = _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,
|
|
icon=icon_service.copy(stack_id, new_id, source.icon),
|
|
)
|
|
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)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# icon
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@router.get("/{stack_id}/icon")
|
|
async def get_stack_icon(
|
|
stack_id: str,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> FileResponse:
|
|
"""Serve a stack's image icon — an upload, or the app logo it resolved to.
|
|
|
|
Authenticated like everything else, which is why the frontend fetches it
|
|
through the API client and renders the blob rather than pointing an
|
|
``<img src>`` straight at this URL (that request would carry no token).
|
|
It is also what keeps the browser off the icon CDN: an app logo is
|
|
downloaded once by this process and served from disk from then on.
|
|
"""
|
|
stack = _get_stack_or_404(session, stack_id)
|
|
if (path := icon_service.file_for(stack_id, stack.icon)):
|
|
ext = icon_service.custom_ext(stack.icon) or ""
|
|
return _icon_response(path, icon_service.content_type(ext), f"{stack_id}.{ext}")
|
|
|
|
slug = icon_service.logo_slug(stack.icon) or icon_service.logo_slug(_auto_icon(stack))
|
|
if slug and (path := await logo_service.ensure_logo(slug)):
|
|
return _icon_response(path, "image/png", f"{slug}.png")
|
|
raise HTTPException(status_code=404, detail="This stack has no image icon")
|
|
|
|
|
|
def _icon_response(path: str, media_type: str, filename: str) -> FileResponse:
|
|
return FileResponse(
|
|
path,
|
|
media_type=media_type,
|
|
# An SVG opened as a top-level document would run its own script in the
|
|
# API's origin. Nothing here is ever meant to be a document.
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@router.post("/{stack_id}/icon")
|
|
async def upload_stack_icon(
|
|
stack_id: str,
|
|
request: Request,
|
|
file: UploadFile = File(...),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
"""Replace a stack's icon with an uploaded image."""
|
|
stack = _get_stack_or_404(session, stack_id)
|
|
data = await file.read(icon_service.MAX_ICON_BYTES + 1)
|
|
try:
|
|
stack.icon = icon_service.store_upload(stack_id, data)
|
|
except icon_service.IconError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
stack.updated_at = compose_service.now()
|
|
session.add(stack)
|
|
session.commit()
|
|
session.refresh(stack)
|
|
audit_service.record(
|
|
session, user=user.username, action="stack.icon", target=stack_id,
|
|
detail=f"uploaded {file.filename or 'image'}", ip=_client_ip(request),
|
|
)
|
|
return _stack_summary(stack)
|
|
|
|
|
|
@router.delete("/{stack_id}/icon")
|
|
def reset_stack_icon(
|
|
stack_id: str,
|
|
request: Request,
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
"""Drop any explicit choice and go back to the name-derived icon."""
|
|
stack = _get_stack_or_404(session, stack_id)
|
|
icon_service.remove(stack_id)
|
|
stack.icon = None
|
|
stack.updated_at = compose_service.now()
|
|
session.add(stack)
|
|
session.commit()
|
|
session.refresh(stack)
|
|
audit_service.record(
|
|
session, user=user.username, action="stack.icon", target=stack_id,
|
|
detail="reset to automatic", 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)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The lock: what holds a stack, and getting out from under it
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
#: How long ``unlock`` waits for a cancelled job to unwind. Cancelling removes
|
|
#: the helper container the backup streams through, so the read fails at once;
|
|
#: what takes the time afterwards is compose starting the containers the
|
|
#: backup stopped, which is what we want to have happened before we answer.
|
|
CANCEL_WAIT_SECONDS = 20.0
|
|
_CANCEL_POLL = 0.25
|
|
|
|
|
|
@router.get("/{stack_id}/lock")
|
|
def stack_lock(
|
|
stack_id: str,
|
|
session: Session = Depends(get_session),
|
|
_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
"""What is holding this stack, if anything.
|
|
|
|
The stacks list only says "updating"; this says *what*, since when, and
|
|
whether it can be stopped.
|
|
"""
|
|
_get_stack_or_404(session, stack_id)
|
|
return {"locked": stack_lock_service.is_busy(session, stack_id),
|
|
"lock": _lock_view(stack_lock_service.info(session, stack_id), stack_id)}
|
|
|
|
|
|
@router.post("/{stack_id}/unlock")
|
|
async def unlock_stack(
|
|
stack_id: str,
|
|
request: Request,
|
|
force: bool = Query(
|
|
False,
|
|
description="Drop the lock even when the operation could not be stopped.",
|
|
),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(require_admin),
|
|
) -> dict:
|
|
"""Stop whatever holds the stack, so it can be used again.
|
|
|
|
A backup is asked to stop and given a moment to unwind — it throws its
|
|
half-written archive away, starts the containers it stopped for a
|
|
consistent snapshot, and drops the lock itself. That is the clean path and
|
|
the one to try first.
|
|
|
|
``force`` is the other one: it deletes the lock row without stopping
|
|
anything. For a lock left behind by an operation that is no longer running
|
|
that is all that is needed; while something *is* still running it means
|
|
the next deploy and that operation can collide, so it stays an explicit
|
|
second step.
|
|
"""
|
|
_get_stack_or_404(session, stack_id)
|
|
lock = stack_lock_service.info(session, stack_id)
|
|
job = stack_job_service.get(stack_id)
|
|
if not lock and not job:
|
|
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' is not locked")
|
|
action = lock["action"] if lock else job.action
|
|
|
|
cancelled = False
|
|
if job is not None and job.cancellable:
|
|
stack_job_service.cancel(stack_id, user.username)
|
|
cancelled = True
|
|
deadline = asyncio.get_running_loop().time() + CANCEL_WAIT_SECONDS
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
await asyncio.sleep(_CANCEL_POLL)
|
|
# The job releases the lock from its own session; ours would
|
|
# otherwise answer from its identity map.
|
|
session.expire_all()
|
|
if not stack_lock_service.is_busy(session, stack_id):
|
|
break
|
|
|
|
session.expire_all()
|
|
released = not stack_lock_service.is_busy(session, stack_id)
|
|
forced = False
|
|
if not released:
|
|
# A lock left behind by a backup that is no longer running guards
|
|
# nothing, so it goes without being asked twice.
|
|
orphaned = _is_orphaned(action, stack_job_service.get(stack_id))
|
|
if not force and not orphaned:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(
|
|
f"The {action} of '{stack_id}' did not stop in time"
|
|
if cancelled
|
|
else f"The {action} of '{stack_id}' cannot be stopped from here"
|
|
)
|
|
+ " — release the lock with force to use the stack anyway.",
|
|
)
|
|
stack_lock_service.release(session, stack_id)
|
|
released, forced = True, not orphaned
|
|
|
|
audit_service.record(
|
|
session, user=user.username, action="stack.unlock", target=stack_id,
|
|
detail=f"{action}{' cancelled' if cancelled else ''}{' forced' if forced else ''}",
|
|
ip=_client_ip(request),
|
|
)
|
|
logger.info(
|
|
"Lock on '%s' (%s) released by %s%s%s",
|
|
stack_id, action, user.username,
|
|
" after cancelling the job" if cancelled else "",
|
|
" (forced)" if forced else "",
|
|
)
|
|
return {"ok": True, "action": action, "cancelled": cancelled,
|
|
"released": released, "forced": forced}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 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(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(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(policy, stack_id)
|