A pass over every feature against a real Docker daemon, then clicking through the UI as admin and read-only user, on a desktop and a phone. Security. /ws/deploy ran compose up for the read-only role; it is admin-only now. A container's inspect view, a stack's compose file (POSTGRES_PASSWORD: hunter2) and the viewer's .env tab no longer show credentials to that role. Git branch and URL values that git would read as options (-x, ext::) are refused. SFTP host keys are checked: pinned on first use or entered by hand (key line or SHA256 fingerprint), a different key is refused before the password is sent. A backup name from the API can no longer reach outside the destination folder. The file browser refuses to move or rename a browse root, as delete did. Docker lifecycle. Closing the deploy console released the stack lock while compose was still running and logged rc=None as success; deploys and updates are now background runs that hold the lock to the end, report the real exit code and can be re-attached to. Every closed log view left a `compose logs -f` behind. A dozen open tabs exhausted the default thread pool and hung the API; event and exec streams have their own threads. Auto-update and the update endpoint ignored failed pulls. A deleted git stack was recreated and started by the next webhook or poll; deleting a stack now drops its git source, auto-update policy and schedule entries. Deleting no longer walks into mounted shares, is refused while a backup runs, and removes the containers even when the compose file is broken. Stack folders compose has to normalise (Nextcloud -> nextcloud) work everywhere and group correctly on the volumes, networks and images pages. Detection. Host port conflicts read the container's own network namespace; /proc/1/net is read instead. Images referenced as docker.io/… were never update-checked; local builds no longer report "needs credentials". NVIDIA GPUs are read from the driver's procfs. The backup dialog's "path mismatch" warning came from mountinfo's filesystem-relative root and fired for any stacks folder on its own disk, ZFS dataset or btrfs subvolume; the host path now comes from Docker's mounts. Backups and destinations. The download ignored the folder/volume selection (axios sent binds[]). Restoring under a new name kept the old one. A report header past nginx's buffer turned a good backup into a 502. SFTP/S3 errors were bare 500s and "Test" did not try to write. A destination used by a schedule could be deleted. Backup file names are in the schedule's or the browser's time zone instead of UTC. Editor and converter. `docker run` conversion produced invalid compose for --network host, named volumes and -dit; --gpus and --mount were dropped; a multi-line command pasted into the one-line field became `image: ' '` (now a textarea, and flattened continuations are read). Validation failed for env_file stacks. The editor overwrote unsaved edits on every container event, lost them on a stray click (now asks), threw when closing the diff, and its helper panel listed the starter service "app" for every existing stack. Monaco is bundled instead of loaded from jsdelivr, so the editor works offline (and monaco-editor 0.57 drops the dompurify advisory; axios and react-router updated). UI. All times were two hours off: offset-less UTC from SQLite was read as local time, and "next run" always said "just now". A token refresh mid-session duplicated deploys, reset terminals and doubled logs. A backend restart (update, self-update) signed everyone out on reload; the page now waits for it. The editor was unusable on a phone, tables and tabs overflowed, dialog text was black on the dark theme, and the file browser opened on an error with the default roots. Read-only users get "Admin only" pages instead of 403s; in-use networks offer no delete; "Scan all" covers StackPilot's own stacks only and runs in the background instead of timing out at 600s. Deployment. nginx passes the port in Host (the OIDC redirect URI lost :5009) and keeps an outer proxy's X-Forwarded-Proto. The frontend waits for a healthy backend (healthcheck probes every 2s while starting). The frontend image builds with npm ci. Secrets are 0644 in a 0700 folder so non-root containers can read them; existing files are fixed at startup. The test suite no longer starts real containers on a host with Docker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fx7gcuy8d6nPd95g43rGHu
729 lines
26 KiB
Python
729 lines
26 KiB
Python
"""File-based stack storage and Docker Compose lifecycle.
|
||
|
||
The compose YAML on disk is always the source of truth. The database only
|
||
stores metadata (name, description, timestamps).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import os
|
||
import re
|
||
import shutil
|
||
import stat
|
||
import tempfile
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from typing import Optional
|
||
|
||
from config import settings
|
||
from docker_client import DockerError, get_client, safe_call
|
||
from services import registry_service
|
||
|
||
COMPOSE_FILENAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
|
||
DEFAULT_COMPOSE_NAME = "compose.yaml"
|
||
COMPOSE_LABEL = "com.docker.compose.project"
|
||
SERVICE_LABEL = "com.docker.compose.service"
|
||
|
||
|
||
class StackFileError(Exception):
|
||
pass
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Slug / paths
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
_PROJECT_INVALID_RE = re.compile(r"[^a-z0-9_-]+")
|
||
|
||
|
||
def project_name(stack_id: str) -> str:
|
||
"""The compose project name for a stack.
|
||
|
||
The same normalisation compose applies to a directory name when it picks
|
||
the project name itself (lowercase, only ``a-z0-9_-``, no leading ``-``/``_``).
|
||
For every stack StackPilot creates that is the id unchanged. A folder found
|
||
on disk as ``Nextcloud`` or ``my.app`` used to be passed as ``-p Nextcloud``,
|
||
which compose refuses — so every action on such a stack failed, while
|
||
``docker compose up`` in that folder worked.
|
||
"""
|
||
return _PROJECT_INVALID_RE.sub("", stack_id.lower()).lstrip("_-") or "stack"
|
||
|
||
|
||
def stack_ids_by_project(stack_ids) -> dict[str, str]:
|
||
"""``{project: stack_id}``, to key per-project Docker data by stack id."""
|
||
return {project_name(sid): sid for sid in stack_ids}
|
||
|
||
|
||
def stack_ids_by_project_in(session) -> dict[str, str]:
|
||
""":func:`stack_ids_by_project` for every stack in the database."""
|
||
from sqlmodel import select
|
||
|
||
from models.stack import Stack
|
||
|
||
return stack_ids_by_project(s.id for s in session.exec(select(Stack)).all())
|
||
|
||
|
||
def owner_stack(project: Optional[str], ids: dict[str, str]) -> Optional[str]:
|
||
"""The stack id behind a ``com.docker.compose.project`` label, else the
|
||
label itself (a project StackPilot does not manage)."""
|
||
return ids.get(project, project) if project else project
|
||
|
||
|
||
def slugify(name: str) -> str:
|
||
slug = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower())
|
||
slug = re.sub(r"-{2,}", "-", slug).strip("-_")
|
||
return slug or "stack"
|
||
|
||
|
||
def stacks_root(override: Optional[str] = None) -> str:
|
||
return override or settings.STACKS_DIR
|
||
|
||
|
||
def stack_dir(stack_id: str, override: Optional[str] = None) -> str:
|
||
return os.path.join(stacks_root(override), stack_id)
|
||
|
||
|
||
def find_compose_file(directory: str) -> Optional[str]:
|
||
for name in COMPOSE_FILENAMES:
|
||
candidate = os.path.join(directory, name)
|
||
if os.path.isfile(candidate):
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def compose_path(stack_id: str, override: Optional[str] = None) -> str:
|
||
directory = stack_dir(stack_id, override)
|
||
return find_compose_file(directory) or os.path.join(directory, DEFAULT_COMPOSE_NAME)
|
||
|
||
|
||
def env_path(stack_id: str, override: Optional[str] = None) -> str:
|
||
return os.path.join(stack_dir(stack_id, override), ".env")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Read / write files
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def read_compose(stack_id: str, override: Optional[str] = None) -> str:
|
||
path = compose_path(stack_id, override)
|
||
if not os.path.isfile(path):
|
||
return ""
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
return fh.read()
|
||
|
||
|
||
def read_env(stack_id: str, override: Optional[str] = None) -> str:
|
||
path = env_path(stack_id, override)
|
||
if not os.path.isfile(path):
|
||
return ""
|
||
with open(path, "r", encoding="utf-8") as fh:
|
||
return fh.read()
|
||
|
||
|
||
#: Name parts that mark a value as a credential (DB_PASSWORD, JWT_SECRET,
|
||
#: CF_API_KEY, TS_AUTHKEY …), matched per ``_``/``.``/``-`` separated part so
|
||
#: that KEYCLOAK_ADMIN or a "monkey" service stay readable.
|
||
_SECRET_PARTS = {
|
||
"pass", "passwd", "password", "passphrase", "pwd", "secret", "token", "key",
|
||
"apikey", "authkey", "credential", "credentials", "private",
|
||
}
|
||
#: ``KEY: value`` and ``- KEY=value``, keeping indentation and quotes.
|
||
_MAPPING_LINE = re.compile(r"^(\s*)([\"']?)([A-Za-z0-9_.-]+)\2(\s*:\s+)(.+?)\s*$")
|
||
_LIST_LINE = re.compile(r"^(\s*-\s*)([\"']?)([A-Za-z0-9_.-]+)=(.*?)\2\s*$")
|
||
#: ``${VAR}``, ``$VAR`` or ``${VAR:?message}`` — but not ``${VAR:-default}``,
|
||
#: whose default is a value like any other.
|
||
_ONLY_A_VARIABLE = re.compile(
|
||
r"^[\"']?\$(?:\{[A-Za-z_][A-Za-z0-9_]*(?::?\?[^}]*)?\}|[A-Za-z_][A-Za-z0-9_]*)[\"']?$"
|
||
)
|
||
MASK = "••••••"
|
||
|
||
|
||
def _secret_name(name: str) -> bool:
|
||
return any(part in _SECRET_PARTS for part in re.split(r"[_.-]", name.lower()))
|
||
|
||
|
||
def mask_secrets(compose: str) -> str:
|
||
"""The compose file with credential values replaced by ``••••••``.
|
||
|
||
For the read-only role, which is not given the ``.env`` — but plenty of
|
||
compose files carry the password inline (``POSTGRES_PASSWORD: hunter2``),
|
||
and the Compose tab showed it. A reference to a variable (``${DB_PASS}``)
|
||
is not a secret and stays. Line-based on purpose: the file keeps its
|
||
layout and comments, and a file that does not parse is masked all the same.
|
||
"""
|
||
out = []
|
||
for line in compose.splitlines(keepends=True):
|
||
body = line.rstrip("\r\n")
|
||
ending = line[len(body):]
|
||
m = _LIST_LINE.match(body)
|
||
if m and _secret_name(m.group(3)) and m.group(4) and not _ONLY_A_VARIABLE.match(m.group(4)):
|
||
body = f"{m.group(1)}{m.group(2)}{m.group(3)}={MASK}{m.group(2)}"
|
||
else:
|
||
m = _MAPPING_LINE.match(body)
|
||
if (
|
||
m
|
||
and _secret_name(m.group(3))
|
||
and not m.group(5).startswith(("|", ">", "{", "[", "&", "*", "#"))
|
||
and not _ONLY_A_VARIABLE.match(m.group(5))
|
||
):
|
||
body = f"{m.group(1)}{m.group(2)}{m.group(3)}{m.group(2)}{m.group(4)}{MASK}"
|
||
out.append(body + ending)
|
||
return "".join(out)
|
||
|
||
|
||
def write_compose(stack_id: str, content: str, override: Optional[str] = None) -> None:
|
||
directory = stack_dir(stack_id, override)
|
||
os.makedirs(directory, exist_ok=True)
|
||
path = compose_path(stack_id, override)
|
||
# Non-destructive: back up existing file first.
|
||
if os.path.isfile(path):
|
||
shutil.copy2(path, path + ".bak")
|
||
tmp = path + ".tmp"
|
||
with open(tmp, "w", encoding="utf-8") as fh:
|
||
fh.write(content)
|
||
os.replace(tmp, path)
|
||
|
||
|
||
def write_env(stack_id: str, content: str, override: Optional[str] = None) -> None:
|
||
directory = stack_dir(stack_id, override)
|
||
os.makedirs(directory, exist_ok=True)
|
||
path = env_path(stack_id, override)
|
||
with open(path, "w", encoding="utf-8") as fh:
|
||
fh.write(content)
|
||
|
||
|
||
def _mount_points() -> set[str]:
|
||
"""Every mount point this process sees (from /proc/self/mountinfo)."""
|
||
points: set[str] = set()
|
||
try:
|
||
with open("/proc/self/mountinfo", "r", encoding="utf-8") as fh:
|
||
for line in fh:
|
||
fields = line.split()
|
||
if len(fields) >= 5:
|
||
# Spaces etc. are octal-escaped in mountinfo.
|
||
points.add(fields[4].encode().decode("unicode_escape"))
|
||
except OSError:
|
||
pass
|
||
return points
|
||
|
||
|
||
def delete_stack_files(stack_id: str, override: Optional[str] = None) -> list[str]:
|
||
"""Remove the stack folder, but never what is mounted inside it.
|
||
|
||
A stack folder can have a NAS share or a second disk mounted in it (a media
|
||
library at ``./media``). ``rmtree`` would have walked straight into it and
|
||
deleted the share's contents along with the compose file. Directories on
|
||
another filesystem, or that are mount points of their own (a bind mount of
|
||
the same disk), are left exactly as they are. Returns those paths.
|
||
"""
|
||
directory = stack_dir(stack_id, override)
|
||
if not os.path.isdir(directory):
|
||
return []
|
||
mounts = _mount_points()
|
||
root_dev = os.lstat(directory).st_dev
|
||
kept: list[str] = []
|
||
|
||
def remove(path: str) -> None:
|
||
with os.scandir(path) as it:
|
||
entries = list(it)
|
||
for entry in entries:
|
||
if entry.is_dir(follow_symlinks=False):
|
||
if (
|
||
entry.stat(follow_symlinks=False).st_dev != root_dev
|
||
or entry.path in mounts
|
||
):
|
||
kept.append(entry.path)
|
||
continue
|
||
remove(entry.path)
|
||
else:
|
||
os.unlink(entry.path)
|
||
try:
|
||
os.rmdir(path)
|
||
except OSError:
|
||
pass # still holds something that was kept
|
||
|
||
remove(directory)
|
||
return kept
|
||
|
||
|
||
def _skip_special_files(directory: str, names: list[str]) -> list[str]:
|
||
"""copytree ignore hook: sockets, FIFOs and devices cannot be copied."""
|
||
skipped = []
|
||
for name in names:
|
||
try:
|
||
mode = os.lstat(os.path.join(directory, name)).st_mode
|
||
except OSError:
|
||
continue
|
||
if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode) or stat.S_ISLNK(mode)):
|
||
skipped.append(name)
|
||
return skipped
|
||
|
||
|
||
def clone_stack_files(src_id: str, dst_id: str, override: Optional[str] = None) -> None:
|
||
src = stack_dir(src_id, override)
|
||
dst = stack_dir(dst_id, override)
|
||
if os.path.isdir(dst):
|
||
raise StackFileError(f"Target stack '{dst_id}' already exists")
|
||
try:
|
||
# symlinks=True copies links as links: following them would duplicate
|
||
# whatever they point at (or fail on a dangling one). A container's
|
||
# runtime socket in a data folder would fail the whole copy.
|
||
shutil.copytree(src, dst, symlinks=True, ignore=_skip_special_files)
|
||
except (OSError, shutil.Error) as exc:
|
||
shutil.rmtree(dst, ignore_errors=True)
|
||
raise StackFileError(f"Could not copy the stack folder: {exc}") from exc
|
||
|
||
|
||
def force_remove_project(stack_id: str) -> int:
|
||
"""Remove a project's containers and networks through the Docker API.
|
||
|
||
The fallback for deleting a stack whose ``compose down`` failed — typically
|
||
because the compose file no longer parses. Without it the containers kept
|
||
running with nothing left in StackPilot to stop them. Returns how many
|
||
containers went.
|
||
"""
|
||
client = get_client()
|
||
label = f"{COMPOSE_LABEL}={project_name(stack_id)}"
|
||
removed = 0
|
||
for container in safe_call(client.containers.list, all=True, filters={"label": label}):
|
||
try:
|
||
container.remove(force=True)
|
||
removed += 1
|
||
except Exception: # noqa: BLE001 - best effort, report what went
|
||
pass
|
||
try:
|
||
for network in safe_call(client.networks.list, filters={"label": label}):
|
||
try:
|
||
network.remove()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
except DockerError:
|
||
pass
|
||
return removed
|
||
|
||
|
||
def discover_stacks(override: Optional[str] = None) -> list[str]:
|
||
"""Return ids of all directories under STACKS_DIR that contain a compose file."""
|
||
root = stacks_root(override)
|
||
if not os.path.isdir(root):
|
||
return []
|
||
found = []
|
||
for entry in sorted(os.listdir(root)):
|
||
# Hidden directories are StackPilot's own scratch space (a restore
|
||
# stages there before swapping it in), never a stack.
|
||
if entry.startswith("."):
|
||
continue
|
||
directory = os.path.join(root, entry)
|
||
if os.path.isdir(directory) and find_compose_file(directory):
|
||
found.append(entry)
|
||
return found
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Live status from Docker
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
@dataclass
|
||
class ContainerInfo:
|
||
id: str
|
||
name: str
|
||
service: str
|
||
image: str
|
||
state: str # running, exited, ...
|
||
status: str # human string
|
||
health: Optional[str] = None
|
||
ports: list[dict] = field(default_factory=list)
|
||
created: Optional[str] = None
|
||
|
||
|
||
def _parse_ports(attrs: dict) -> list[dict]:
|
||
ports = []
|
||
bindings = (attrs.get("NetworkSettings") or {}).get("Ports") or {}
|
||
for container_port, host in (bindings or {}).items():
|
||
if host:
|
||
for binding in host:
|
||
ports.append(
|
||
{
|
||
"container": container_port,
|
||
"host_ip": binding.get("HostIp"),
|
||
"host_port": binding.get("HostPort"),
|
||
}
|
||
)
|
||
else:
|
||
ports.append({"container": container_port, "host_port": None})
|
||
return ports
|
||
|
||
|
||
def containers_for_stack(stack_id: str) -> list[ContainerInfo]:
|
||
client = get_client()
|
||
raw = safe_call(
|
||
client.containers.list,
|
||
all=True,
|
||
filters={"label": f"{COMPOSE_LABEL}={project_name(stack_id)}"},
|
||
)
|
||
result = []
|
||
for c in raw:
|
||
attrs = c.attrs
|
||
state = attrs.get("State", {}) or {}
|
||
health = (state.get("Health") or {}).get("Status")
|
||
result.append(
|
||
ContainerInfo(
|
||
id=c.id,
|
||
name=c.name,
|
||
service=c.labels.get(SERVICE_LABEL, c.name),
|
||
# Use the configured image name from the inspect we already have;
|
||
# reading c.image.tags would trigger a separate image-inspect call
|
||
# per container.
|
||
image=attrs.get("Config", {}).get("Image", "") or attrs.get("Image", ""),
|
||
state=state.get("Status", c.status),
|
||
status=attrs.get("State", {}).get("Status", c.status),
|
||
health=health,
|
||
ports=_parse_ports(attrs),
|
||
created=attrs.get("Created"),
|
||
)
|
||
)
|
||
return result
|
||
|
||
|
||
def _status_from_states(states: list[str]) -> str:
|
||
if not states:
|
||
return "stopped"
|
||
if any(s == "dead" for s in states):
|
||
return "error"
|
||
running = [s for s in states if s == "running"]
|
||
if len(running) == len(states):
|
||
return "running"
|
||
if running:
|
||
return "partial"
|
||
return "stopped"
|
||
|
||
|
||
def compute_status(stack_id: str, containers: Optional[list[ContainerInfo]] = None) -> str:
|
||
"""Status for one stack, from its containers alone.
|
||
|
||
"updating" is not derived here: whether an operation is in flight lives in
|
||
``stack_lock_service``, and callers that want to show it overlay the lock on
|
||
top of this. Pass already-fetched ``containers`` to avoid a redundant Docker
|
||
round-trip (the detail view already has them).
|
||
"""
|
||
try:
|
||
if containers is None:
|
||
containers = containers_for_stack(stack_id)
|
||
except DockerError:
|
||
return "unknown"
|
||
return _status_from_states([c.state for c in containers])
|
||
|
||
|
||
def stack_status_summaries() -> dict[str, dict]:
|
||
"""One cheap Docker call → ``{project: {status, total, running}}`` for every
|
||
stack at once.
|
||
|
||
Uses the low-level container *summary* list (``GET /containers/json``, no
|
||
per-container inspect) grouped by the compose project label. This is far
|
||
cheaper than calling :func:`containers_for_stack` (which full-inspects every
|
||
container) once per stack for the overview — turning the stacks list from
|
||
O(stacks × containers) Docker round-trips into a single one.
|
||
"""
|
||
client = get_client()
|
||
raw = safe_call(client.api.containers, all=True)
|
||
by_project: dict[str, list[str]] = {}
|
||
for c in raw:
|
||
project = (c.get("Labels") or {}).get(COMPOSE_LABEL)
|
||
if project:
|
||
by_project.setdefault(project, []).append(c.get("State", ""))
|
||
return {
|
||
project: {
|
||
"status": _status_from_states(states),
|
||
"total": len(states),
|
||
"running": sum(1 for s in states if s == "running"),
|
||
}
|
||
for project, states in by_project.items()
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Compose CLI lifecycle (async subprocess)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _compose_base_cmd(stack_id: str, override: Optional[str] = None) -> list[str]:
|
||
directory = stack_dir(stack_id, override)
|
||
compose_file = find_compose_file(directory) or os.path.join(directory, DEFAULT_COMPOSE_NAME)
|
||
return [
|
||
"docker",
|
||
"compose",
|
||
"-p",
|
||
project_name(stack_id),
|
||
"--project-directory",
|
||
directory,
|
||
"-f",
|
||
compose_file,
|
||
]
|
||
|
||
|
||
async def run_compose(
|
||
stack_id: str,
|
||
args: list[str],
|
||
override: Optional[str] = None,
|
||
timeout: float = 600.0,
|
||
) -> dict:
|
||
"""Run a `docker compose` subcommand. Returns {returncode, stdout, stderr}."""
|
||
cmd = _compose_base_cmd(stack_id, override) + args
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*cmd,
|
||
env=registry_service.cli_env(),
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
)
|
||
try:
|
||
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||
except asyncio.TimeoutError as exc:
|
||
proc.kill()
|
||
await proc.wait()
|
||
raise StackFileError(f"compose command timed out: {' '.join(args)}") from exc
|
||
return {
|
||
"returncode": proc.returncode,
|
||
"stdout": stdout_b.decode("utf-8", "replace"),
|
||
"stderr": stderr_b.decode("utf-8", "replace"),
|
||
"command": " ".join(args),
|
||
}
|
||
|
||
|
||
async def validate_yaml(
|
||
content: str, env_content: str = "", project_dir: Optional[str] = None
|
||
) -> dict:
|
||
"""Validate compose YAML by running ``docker compose config -q`` against it.
|
||
Returns ``{"ok": bool, "errors": str}`` — the errors string carries
|
||
compose's own diagnostics (parse errors, unknown keys, bad interpolation)
|
||
so the editor can surface them before saving.
|
||
|
||
With ``project_dir`` (an existing stack's folder) relative paths resolve
|
||
against that folder, as they will on deploy. Validated from a bare temp
|
||
directory, a stack using ``env_file: ./app.env`` — or an ``include``, a
|
||
``secrets: file:`` — was reported broken although it deploys fine. The
|
||
edited ``.env`` is still the one used for interpolation.
|
||
"""
|
||
with tempfile.TemporaryDirectory(prefix="sp-validate-") as tmp:
|
||
compose_file = os.path.join(tmp, DEFAULT_COMPOSE_NAME)
|
||
with open(compose_file, "w", encoding="utf-8") as fh:
|
||
fh.write(content)
|
||
env_file = os.path.join(tmp, ".env")
|
||
with open(env_file, "w", encoding="utf-8") as fh:
|
||
fh.write(env_content or "")
|
||
directory = project_dir if project_dir and os.path.isdir(project_dir) else tmp
|
||
cmd = [
|
||
"docker", "compose",
|
||
"--project-directory", directory,
|
||
"-f", compose_file,
|
||
"--env-file", env_file,
|
||
"config", "-q",
|
||
]
|
||
if directory != tmp:
|
||
# The folder's name may not be a valid project name; config does
|
||
# not care what the project is called.
|
||
cmd[2:2] = ["-p", "sp-validate"]
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*cmd,
|
||
env=registry_service.cli_env(),
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
)
|
||
try:
|
||
_out, err = await asyncio.wait_for(proc.communicate(), timeout=30.0)
|
||
except asyncio.TimeoutError as exc:
|
||
proc.kill()
|
||
await proc.wait()
|
||
raise StackFileError("compose config validation timed out") from exc
|
||
ok = proc.returncode == 0
|
||
errors = "" if ok else err.decode("utf-8", "replace").strip()
|
||
# Name the file the user is editing, not our scratch copy of it.
|
||
errors = errors.replace(compose_file, DEFAULT_COMPOSE_NAME).replace(env_file, ".env")
|
||
errors = errors.replace(tmp + os.sep, "./")
|
||
return {"ok": ok, "errors": errors}
|
||
|
||
|
||
async def stream_compose(
|
||
stack_id: str, args: list[str], override: Optional[str] = None
|
||
):
|
||
"""Yield lines from a `docker compose` subcommand as they are produced.
|
||
|
||
The subprocess is killed when the consumer stops early (the generator is
|
||
closed or its task cancelled). For ``logs -f`` that is the only way it ever
|
||
ends: without this every log view that was closed left a ``compose logs``
|
||
process behind for as long as the containers lived.
|
||
"""
|
||
cmd = _compose_base_cmd(stack_id, override) + args
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*cmd,
|
||
env=registry_service.cli_env(),
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.STDOUT,
|
||
)
|
||
assert proc.stdout is not None
|
||
try:
|
||
async for raw in proc.stdout:
|
||
yield raw.decode("utf-8", "replace").rstrip("\n")
|
||
await proc.wait()
|
||
finally:
|
||
if proc.returncode is None:
|
||
try:
|
||
proc.kill()
|
||
except ProcessLookupError:
|
||
pass
|
||
# Reap it, so it does not linger as a zombie. Shielded: this runs
|
||
# while the consuming task is being cancelled.
|
||
try:
|
||
await asyncio.shield(proc.wait())
|
||
except (asyncio.CancelledError, ProcessLookupError):
|
||
pass
|
||
|
||
|
||
_json_progress: Optional[bool] = None
|
||
|
||
|
||
async def supports_json_progress() -> bool:
|
||
"""Whether this Docker Compose understands ``--progress json``.
|
||
|
||
The JSON progress stream carries per-layer ``current``/``total`` bytes, which
|
||
the deploy console turns into a real progress bar. Older compose releases
|
||
reject the value, so probe once (cheap, no side effects) and cache it; on a
|
||
negative result callers fall back to the plain text stream.
|
||
"""
|
||
global _json_progress
|
||
if _json_progress is None:
|
||
try:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
"docker", "compose", "--progress", "json", "version",
|
||
env=registry_service.cli_env(),
|
||
stdout=asyncio.subprocess.DEVNULL,
|
||
stderr=asyncio.subprocess.DEVNULL,
|
||
)
|
||
rc = await asyncio.wait_for(proc.wait(), timeout=15.0)
|
||
_json_progress = rc == 0
|
||
except Exception: # noqa: BLE001 - probe failure just disables the feature
|
||
_json_progress = False
|
||
return _json_progress
|
||
|
||
|
||
async def _stream_phase(
|
||
stack_id: str, args: list[str], override: Optional[str], json_progress: bool
|
||
):
|
||
"""One compose subcommand, streamed. Yields ``("log", line)`` per output
|
||
line, then ``("rc", returncode)`` exactly once."""
|
||
cmd = _compose_base_cmd(stack_id, override)
|
||
if json_progress:
|
||
# Global flag, must precede the subcommand.
|
||
cmd += ["--progress", "json"]
|
||
cmd += args
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*cmd,
|
||
env=registry_service.cli_env(),
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.STDOUT,
|
||
)
|
||
assert proc.stdout is not None
|
||
async for raw in proc.stdout:
|
||
yield ("log", raw.decode("utf-8", "replace").rstrip("\n"))
|
||
await proc.wait()
|
||
yield ("rc", proc.returncode)
|
||
|
||
|
||
async def stream_up(stack_id: str, override: Optional[str] = None):
|
||
"""Run `compose up -d` streaming combined output, so the deploy console can
|
||
show image-pull and container-create progress live.
|
||
|
||
Yields ``("log", line)`` for each output line, then ``("done", returncode)``.
|
||
"""
|
||
json_progress = await supports_json_progress()
|
||
async for kind, payload in _stream_phase(
|
||
stack_id, ["up", "-d", "--remove-orphans"], override, json_progress
|
||
):
|
||
yield ("done", payload) if kind == "rc" else ("log", payload)
|
||
|
||
|
||
async def stream_update(stack_id: str, override: Optional[str] = None):
|
||
"""Run `compose pull` then `compose up -d`, streaming both phases, so the
|
||
stacks list can show real update progress instead of a spinner.
|
||
|
||
Yields ``("log", line)`` for each output line of either phase, then
|
||
``("done", returncode)`` once. A failed pull short-circuits: recreating
|
||
containers on images that never came down would only make things worse.
|
||
"""
|
||
json_progress = await supports_json_progress()
|
||
rc = 0
|
||
for args in (["pull"], ["up", "-d", "--remove-orphans"]):
|
||
async for kind, payload in _stream_phase(stack_id, args, override, json_progress):
|
||
if kind == "log":
|
||
yield ("log", payload)
|
||
else:
|
||
rc = payload
|
||
if rc != 0:
|
||
break
|
||
yield ("done", rc)
|
||
|
||
|
||
# Convenience lifecycle wrappers ------------------------------------------------
|
||
|
||
|
||
async def up(stack_id: str, override: Optional[str] = None) -> dict:
|
||
return await run_compose(stack_id, ["up", "-d", "--remove-orphans"], override)
|
||
|
||
|
||
async def down(stack_id: str, override: Optional[str] = None) -> dict:
|
||
return await run_compose(stack_id, ["down"], override)
|
||
|
||
|
||
async def start(stack_id: str, override: Optional[str] = None) -> dict:
|
||
return await run_compose(stack_id, ["start"], override)
|
||
|
||
|
||
async def stop(stack_id: str, override: Optional[str] = None) -> dict:
|
||
return await run_compose(stack_id, ["stop"], override)
|
||
|
||
|
||
async def restart(stack_id: str, override: Optional[str] = None) -> dict:
|
||
return await run_compose(stack_id, ["restart"], override)
|
||
|
||
|
||
async def pull(stack_id: str, override: Optional[str] = None) -> dict:
|
||
return await run_compose(stack_id, ["pull"], override)
|
||
|
||
|
||
async def update(stack_id: str, override: Optional[str] = None) -> dict:
|
||
"""Pull then up -d.
|
||
|
||
A failed pull stops here, like the streamed update does. Running ``up``
|
||
anyway recreated nothing new — the old images were still there — and its
|
||
exit code 0 then reported the update as a success.
|
||
"""
|
||
pull_res = await run_compose(stack_id, ["pull"], override)
|
||
if pull_res["returncode"] != 0:
|
||
return {**pull_res, "command": "pull + up -d"}
|
||
up_res = await run_compose(stack_id, ["up", "-d", "--remove-orphans"], override)
|
||
return {
|
||
"returncode": up_res["returncode"],
|
||
"stdout": pull_res["stdout"] + "\n" + up_res["stdout"],
|
||
"stderr": pull_res["stderr"] + "\n" + up_res["stderr"],
|
||
"command": "pull + up -d",
|
||
}
|
||
|
||
|
||
async def logs(
|
||
stack_id: str,
|
||
service: Optional[str] = None,
|
||
tail: int = 200,
|
||
override: Optional[str] = None,
|
||
) -> dict:
|
||
args = ["logs", "--no-color", "--tail", str(tail), "--timestamps"]
|
||
if service:
|
||
# "--" so a service name can never be read as a compose option.
|
||
args += ["--", service]
|
||
return await run_compose(stack_id, args, override, timeout=60.0)
|
||
|
||
|
||
def now() -> datetime:
|
||
return datetime.now(timezone.utc)
|