furtka/furtka/dockerops.py
Daniel Maksymilian Syrnicki e6f52ada5c feat(furtka): per-app image updates via POST /api/apps/<name>/update
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>
2026-04-16 12:45:47 +02:00

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