Files
stackpilot/backend/services/compose_service.py
T
menzeljandClaude Opus 5.5 d6cebb3371
CI / check (push) Successful in 4m9s
CI / build-and-push (push) Failing after 16m10s
Fix what a review of the whole app found, end to end (0.67.1)
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
2026-10-15 13:51:26 +02:00

729 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)