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:
parent
4e4dc1001f
commit
e6f52ada5c
3 changed files with 255 additions and 6 deletions
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue