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>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-16 12:45:47 +02:00
parent 4e4dc1001f
commit e6f52ada5c
3 changed files with 255 additions and 6 deletions

View file

@ -255,6 +255,7 @@ async function refresh() {
</div>
<div class="buttons">
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
<button class="secondary" data-op="update" data-name="${esc(a.name)}">Update</button>
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
</div>
@ -284,20 +285,30 @@ async function handleButton(op, name, btn) {
openSettingsDialog(name, op === 'install' ? 'install' : 'edit');
return;
}
// Reinstall + remove are direct actions, no form.
// Reinstall + update + remove are direct actions, no form.
btn.disabled = true;
const original = btn.textContent;
btn.textContent = op === 'reinstall' ? 'Reinstalling…' : 'Removing…';
const labels = { reinstall: 'Reinstalling…', update: 'Checking…', remove: 'Removing…' };
btn.textContent = labels[op] || 'Working…';
try {
const url = op === 'reinstall' ? '/api/apps/install' : '/api/apps/remove';
const r = await fetch(url, {
const urls = {
reinstall: '/api/apps/install',
remove: '/api/apps/remove',
update: `/api/apps/${encodeURIComponent(name)}/update`,
};
const r = await fetch(urls[op], {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name}),
});
const data = await r.json();
document.getElementById('log').textContent =
`[${op} ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
let header = `[${op} ${name}] HTTP ${r.status}`;
if (op === 'update' && r.ok) {
header += data.updated
? ` updated ${data.services.length} service(s)`
: ' — already up to date';
}
document.getElementById('log').textContent = header + '\\n' + JSON.stringify(data, null, 2);
} catch (e) {
document.getElementById('log').textContent = `[${op} ${name}] network error: ${e.message}`;
}
@ -470,6 +481,38 @@ def _do_remove(name):
return 200, {"removed": name, "compose_warning": compose_warning}
def _do_update(name):
"""Pull newer container images for an installed app; restart if any changed.
Behaviour:
- 404 if the app isn't installed.
- Always runs `docker compose pull` first (cheap no-op when nothing to
fetch, touches the network).
- For each service, compares the container's running image ID against
the post-pull local image ID. If a service's image advanced, runs
`docker compose up -d` so compose recreates the affected containers
in place.
- Returns {updated: bool, services: [{service, from, to}], ...}.
"""
target = apps_dir() / name
if not target.exists():
return 404, {"error": f"{name!r} is not installed"}
try:
dockerops.compose_pull(target, name)
tags = dockerops.compose_image_tags(target, name)
changes = []
for service, tag in tags.items():
running = dockerops.running_container_image_id(target, name, service)
local = dockerops.local_image_id(tag)
if running and local and running != local:
changes.append({"service": service, "from": running, "to": local, "tag": tag})
if changes:
dockerops.compose_up(target, name)
except dockerops.DockerError as e:
return 502, {"error": str(e)}
return 200, {"app": name, "updated": bool(changes), "services": changes}
def _parse_settings_body(payload):
"""Extract and coerce the settings dict from a JSON body. Returns dict or None."""
s = payload.get("settings")
@ -539,6 +582,14 @@ class _Handler(BaseHTTPRequestHandler):
status, body = _do_update_settings(name, settings)
return self._json(status, body)
# Per-app image update: /api/apps/<name>/update
if self.path.startswith("/api/apps/") and self.path.endswith("/update"):
name = self.path[len("/api/apps/") : -len("/update")]
if "/" in name or not name:
return self._json(400, {"error": "invalid app name"})
status, body = _do_update(name)
return self._json(status, body)
name = payload.get("name")
if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"})

View file

@ -1,3 +1,4 @@
import json
import subprocess
from pathlib import Path
@ -49,3 +50,70 @@ def compose_up(app_dir: Path, project: str) -> None:
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

View file

@ -326,6 +326,136 @@ def test_http_get_settings_route(fake_dirs, no_docker):
server.server_close()
# --- Update endpoint --------------------------------------------------------
@pytest.fixture
def update_docker_stubs(monkeypatch):
"""Stub the dockerops helpers _do_update touches. Tests tune the return
values of running_/local_image_id via `state` to steer the comparison."""
state = {
"tags": {"samba": "dperson/samba:latest"},
"running": {"samba": "sha256:OLD"},
"local": {"samba": "sha256:OLD"},
"pull_called": 0,
"up_called": 0,
"pull_raises": None,
}
def _pull(app_dir, project):
state["pull_called"] += 1
if state["pull_raises"]:
raise state["pull_raises"]
def _up(app_dir, project):
state["up_called"] += 1
monkeypatch.setattr(api.dockerops, "compose_pull", _pull)
monkeypatch.setattr(api.dockerops, "compose_up", _up)
monkeypatch.setattr(
api.dockerops, "compose_image_tags", lambda app_dir, project: dict(state["tags"])
)
monkeypatch.setattr(
api.dockerops,
"running_container_image_id",
lambda app_dir, project, service: state["running"].get(service),
)
monkeypatch.setattr(api.dockerops, "local_image_id", lambda tag: state["local"].get("samba"))
return state
def test_update_not_installed(fake_dirs):
status, body = api._do_update("ghost")
assert status == 404
assert "not installed" in body["error"]
def test_update_no_changes(fake_dirs, no_docker, update_docker_stubs):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
api._do_install("fileshare")
update_docker_stubs["up_called"] = 0 # reset counter after install
status, body = api._do_update("fileshare")
assert status == 200
assert body["updated"] is False
assert body["services"] == []
assert update_docker_stubs["pull_called"] == 1
assert update_docker_stubs["up_called"] == 0
def test_update_changes_applied(fake_dirs, no_docker, update_docker_stubs):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
api._do_install("fileshare")
update_docker_stubs["up_called"] = 0 # reset counter after install
# Simulate: pull advanced the local image.
update_docker_stubs["local"] = {"samba": "sha256:NEW"}
status, body = api._do_update("fileshare")
assert status == 200
assert body["updated"] is True
[change] = body["services"]
assert change == {
"service": "samba",
"from": "sha256:OLD",
"to": "sha256:NEW",
"tag": "dperson/samba:latest",
}
assert update_docker_stubs["up_called"] == 1
def test_update_skips_services_not_running(fake_dirs, no_docker, update_docker_stubs):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
api._do_install("fileshare")
update_docker_stubs["up_called"] = 0 # reset counter after install
# Container not up at all: running_container_image_id returns None.
update_docker_stubs["running"] = {}
update_docker_stubs["local"] = {"samba": "sha256:NEW"}
status, body = api._do_update("fileshare")
assert status == 200
assert body["updated"] is False
assert update_docker_stubs["up_called"] == 0
def test_update_returns_502_on_pull_error(fake_dirs, no_docker, update_docker_stubs):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
api._do_install("fileshare")
update_docker_stubs["up_called"] = 0 # reset counter after install
update_docker_stubs["pull_raises"] = api.dockerops.DockerError("no network")
status, body = api._do_update("fileshare")
assert status == 502
assert "no network" in body["error"]
assert update_docker_stubs["up_called"] == 0
def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real")
api._do_install("fileshare")
update_docker_stubs["up_called"] = 0 # reset counter after install
update_docker_stubs["local"] = {"samba": "sha256:NEW"}
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
try:
req = urllib.request.Request(
f"http://127.0.0.1:{port}/api/apps/fileshare/update",
data=b"{}",
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as r:
assert r.status == 200
body = json.loads(r.read())
assert body["updated"] is True
assert body["services"][0]["service"] == "samba"
finally:
server.shutdown()
server.server_close()
def test_http_post_install_with_settings(fake_dirs, no_docker):
_, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)