Files
stackpilot/backend/routers/stacks.py
T
menzeljandClaude Opus 5.5 06234c1067
CI / check (push) Successful in 4m48s
CI / build-and-push (push) Failing after 16m2s
Run compose like the terminal does, and follow changes made there (0.67.2)
StackPilot called `docker compose -p <folder> -f compose.yaml`, which is
not what `docker compose` typed in the stack's folder does. A
compose.override.yaml or a COMPOSE_FILE in .env applied in a terminal
and was dropped by the next Start in StackPilot, which recreated the
containers without it. A compose file's `name:` or a COMPOSE_PROJECT_NAME
in .env made the terminal's containers invisible: the stack showed as
stopped while running, and Start set up a second copy next to it.

Compose now runs in the stack's folder without -f, so it loads the same
files, and -p is the name compose itself picks there: COMPOSE_PROJECT_NAME
from .env, else the last `name:` among the compose files (${VAR} from
.env), else the folder name. A stack StackPilot already runs under its
folder name keeps that name while it has containers or volumes under it
and none under the declared one: switching would have brought it up
again on empty volumes. Backups record the project, and a restore takes
it from the files it has just put back.

Changes made outside StackPilot now reach every view:

- docker_watch_service reads Docker's events once for the backend. The
  dashboard rollup (cached 25s) and the stats cache are stale as soon as
  the event generation moves; the tiles followed a terminal `compose
  stop` after 15-30s, now within a second.
- Image events re-read the local digests, so a `docker compose pull` in
  a terminal clears the update badge instead of leaving it until the
  next registry check (an hour by default).
- The browser catches up when its event socket reconnects; what changed
  while it was down waited for the 60s poll.
- The stack detail carries a revision of the compose file and .env;
  saving after they changed on disk is refused with 409 and the editor
  offers to overwrite or to load the version on disk. A save used to
  replace a terminal edit without a word.
- A stack whose folder is gone is flagged "folder missing" instead of
  passing for an ordinary stopped stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu
2026-10-15 15:14:40 +02:00

871 lines
33 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 starlette.background import BackgroundTask
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_cleanup_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(compose_service.project_name(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,
# The folder is gone (removed in a terminal, a share not mounted): the
# row stays — its git source, schedules and settings are still here —
# but says so instead of passing for an ordinary stopped stack.
"missing": not os.path.isdir(compose_service.stack_dir(stack.id)),
"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)
def _by_stack_id(session: Session, per_project: dict) -> dict:
"""Re-key per-compose-project data by stack id (they differ only for a
folder whose name compose had to normalise, see project_name)."""
ids = compose_service.stack_ids_by_project_in(session)
return {ids.get(project, project): value for project, value in per_project.items()}
@router.get("/stats")
def stacks_stats(
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
"""Live CPU (cores) and memory usage per stack, with assigned limits."""
return _by_stack_id(session, stats_service.stack_stats())
@router.get("/updates")
def stacks_updates(
session: Session = Depends(get_session),
_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 _by_stack_id(session, 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),
"missing": not os.path.isdir(compose_service.stack_dir(stack_id)),
# Inline credentials masked for the read-only role, like the .env.
"yaml": (
compose_service.read_compose(stack_id)
if user.role == "admin"
else compose_service.mask_secrets(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 "",
# For the editor's save: see StackUpdate.base_revision. Admins only —
# it is a hash over the .env, which this role is not shown.
"revision": compose_service.stack_revision(stack_id) if user.role == "admin" else None,
"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.base_revision and (body.yaml is not None or body.env is not None):
current = compose_service.stack_revision(stack_id)
if current != body.base_revision:
raise HTTPException(
status_code=409,
detail={
"error": "changed_on_disk",
"detail": f"The compose file or .env of '{stack_id}' was changed outside "
"StackPilot after it was opened here",
"revision": current,
},
)
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), "revision": compose_service.stack_revision(stack_id)}
@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)
# Deleting a stack in the middle of a backup, restore or deploy pulls the
# folder out from under it — and a backup then starts the containers it
# stopped again, for a stack that no longer exists.
lock = stack_lock_service.info(session, stack_id)
if lock:
raise HTTPException(
status_code=409,
detail=f"Stack '{stack_id}' is busy: {lock['action']} in progress",
)
try:
result = await compose_service.down(stack_id)
down_ok = result.get("returncode") == 0
except Exception: # noqa: BLE001 - fall back to the API below
down_ok = False
if not down_ok:
# A compose file that no longer parses makes `down` fail, and the
# containers would keep running with nothing left here to stop them.
try:
removed = await asyncio.to_thread(compose_service.force_remove_project, stack_id)
logger.info("compose down failed for '%s'; removed %d container(s) directly",
stack_id, removed)
except DockerError as exc:
logger.warning("Could not remove the containers of '%s': %s", stack_id, exc)
kept: list[str] = []
if delete_files:
try:
kept = compose_service.delete_stack_files(stack_id)
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"The stack was stopped, but its folder could not be deleted: {exc}",
) from exc
icon_service.remove(stack_id)
logo_service.forget(stack_id)
stack_cleanup_service.forget_stack(session, 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}" + (f" kept={len(kept)} mount(s)" if kept else ""),
ip=_client_ip(request),
)
return {"ok": True, "kept": kept}
@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:
status = 409 if "already exists" in str(exc) else 500
raise HTTPException(status_code=status, 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:
# A folder found on disk can be named anything; headers are latin-1.
filename = filename.encode("ascii", "replace").decode("ascii").replace("?", "_")
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
except compose_service.StackFileError as exc:
# The compose call timed out; it may still be finishing on the host.
raise HTTPException(status_code=504, detail=str(exc)) 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, ge=0, 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, ge=0, 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")
tmp.close()
try:
with tarfile.open(tmp.name, "w:gz") as tar:
tar.add(directory, arcname=stack_id)
except BaseException:
os.unlink(tmp.name)
raise
date = compose_service.now().strftime("%Y%m%d")
return FileResponse(
tmp.name,
media_type="application/gzip",
filename=f"stack-{stack_id}-{date}.tar.gz",
# The archive is a temp file; without this every export stayed in /tmp.
background=BackgroundTask(os.unlink, tmp.name),
)
@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:
_get_stack_or_404(session, stack_id)
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)