Phase 1 of updates. User clicks Update on an installed app row →
the resource manager runs `docker compose pull`, compares the
running container's image ID to the just-pulled local image ID
per service, and only runs `docker compose up -d` if something
actually changed. Response is {updated: bool, services: [{service,
from, to, tag}]} so the UI can tell the user what happened.
Deliberately small: no pinning, no background checks, no "update
all" button, no version/changelog display. The update flow doesn't
mutate the compose file — it just acts on what's already there.
Reinstall still serves as rollback.
New dockerops helpers: compose_pull, compose_image_tags (parses
`docker compose config --format json`), local_image_id (via
`docker image inspect`), running_container_image_id (via compose
ps --quiet + docker inspect). Six new tests cover the endpoint:
not installed, no changes, changes applied, service not running,
docker pull error, and the HTTP route end-to-end.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
119 lines
3.6 KiB
Python
119 lines
3.6 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
class DockerError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _run(args: list[str], cwd: Path | None = None, check: bool = True):
|
|
proc = subprocess.run(
|
|
args,
|
|
cwd=cwd,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if check and proc.returncode != 0:
|
|
msg = proc.stderr.strip() or proc.stdout.strip()
|
|
raise DockerError(f"{' '.join(args)} exited {proc.returncode}: {msg}")
|
|
return proc
|
|
|
|
|
|
def volume_exists(name: str) -> bool:
|
|
return _run(["docker", "volume", "inspect", name], check=False).returncode == 0
|
|
|
|
|
|
def ensure_volume(name: str) -> bool:
|
|
# Returns True if the volume was just created, False if it already existed.
|
|
if volume_exists(name):
|
|
return False
|
|
_run(["docker", "volume", "create", name])
|
|
return True
|
|
|
|
|
|
def _compose_args(app_dir: Path, project: str) -> list[str]:
|
|
return [
|
|
"docker",
|
|
"compose",
|
|
"--project-name",
|
|
project,
|
|
"--file",
|
|
str(app_dir / "docker-compose.yaml"),
|
|
]
|
|
|
|
|
|
def compose_up(app_dir: Path, project: str) -> None:
|
|
_run([*_compose_args(app_dir, project), "up", "--detach"], cwd=app_dir)
|
|
|
|
|
|
def compose_down(app_dir: Path, project: str) -> None:
|
|
_run([*_compose_args(app_dir, project), "down"], cwd=app_dir)
|
|
|
|
|
|
def compose_pull(app_dir: Path, project: str) -> None:
|
|
"""Fetch the latest image for every service in the compose file.
|
|
|
|
No-op for images already up to date. Network-bound — can take seconds.
|
|
"""
|
|
_run([*_compose_args(app_dir, project), "pull"], cwd=app_dir)
|
|
|
|
|
|
def compose_image_tags(app_dir: Path, project: str) -> dict[str, str]:
|
|
"""Return {service_name: image_tag} as declared in the compose file.
|
|
|
|
Uses `docker compose config --format json` so we don't have to write a
|
|
YAML parser — compose already resolves env vars and defaults for us.
|
|
"""
|
|
proc = _run(
|
|
[*_compose_args(app_dir, project), "config", "--format", "json"],
|
|
cwd=app_dir,
|
|
)
|
|
try:
|
|
cfg = json.loads(proc.stdout)
|
|
except json.JSONDecodeError as e:
|
|
raise DockerError(f"compose config: invalid JSON: {e}") from e
|
|
services = cfg.get("services") or {}
|
|
return {name: spec.get("image") for name, spec in services.items() if spec.get("image")}
|
|
|
|
|
|
def local_image_id(tag: str) -> str | None:
|
|
"""`sha256:…` image ID of the locally-stored image matching `tag`.
|
|
|
|
Returns None if the tag isn't pulled locally (normal before first pull).
|
|
"""
|
|
proc = _run(
|
|
["docker", "image", "inspect", tag, "--format", "{{.Id}}"],
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
return None
|
|
return proc.stdout.strip() or None
|
|
|
|
|
|
def running_container_image_id(app_dir: Path, project: str, service: str) -> str | None:
|
|
"""`sha256:…` image ID the compose container for `service` was started from.
|
|
|
|
Returns None if the service isn't running. Compose may have multiple
|
|
replicas (scale > 1); in that case the first container's image ID wins,
|
|
which is fine for "is this service on the current image?" checks since
|
|
compose keeps replicas on the same image.
|
|
"""
|
|
proc = _run(
|
|
[*_compose_args(app_dir, project), "ps", "--quiet", service],
|
|
cwd=app_dir,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
return None
|
|
ids = [line for line in proc.stdout.splitlines() if line.strip()]
|
|
if not ids:
|
|
return None
|
|
inspect = _run(
|
|
["docker", "inspect", "--format", "{{.Image}}", ids[0]],
|
|
check=False,
|
|
)
|
|
if inspect.returncode != 0:
|
|
return None
|
|
return inspect.stdout.strip() or None
|