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)