diff --git a/furtka/api.py b/furtka/api.py
index 051d9bc..bba984e 100644
--- a/furtka/api.py
+++ b/furtka/api.py
@@ -255,6 +255,7 @@ async function refresh() {
${hasSettings ? `` : ''}
+
@@ -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//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"})
diff --git a/furtka/dockerops.py b/furtka/dockerops.py
index 769abcb..533593b 100644
--- a/furtka/dockerops.py
+++ b/furtka/dockerops.py
@@ -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
diff --git a/tests/test_api.py b/tests/test_api.py
index c1842d4..cfb902f 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -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)