From 5c58eade1c39496b0325e65e4dc00301c1ea7912 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Thu, 16 Apr 2026 13:44:34 +0200 Subject: [PATCH] feat(furtka): Furtka-updates card on /settings + API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of the self-update story, the user-facing piece. The existing CLI update flow now has a button next to it. API additions (furtka/api.py): - POST /api/furtka/update/check — thin wrapper around updater.check_update - POST /api/furtka/update/apply — pre-checks the lockfile (409 on conflict) then kicks the updater off via systemd-run as a detached transient unit, so the update outlives the furtka-api restart it triggers. Returns 202 with the unit name. - GET /api/furtka/update/status — returns the current update-state.json UI additions (furtka/assets/www/settings/index.html): - New "Furtka updates" card above Appearance showing installed + latest-available versions with Check + Update buttons. - On apply: starts polling /update-state.json every 2s. That file is Caddy-served (not API-served) so the mid-update API restart doesn't interrupt progress reporting. Stage labels get plain-English strings (Downloading release… / Verifying signature… / etc.). On done: 5s grace, then location.reload() so the user sees the new version live. On rolled_back: red status with the reason string. Tests (tests/test_api.py): - 5 new tests covering both endpoint return shapes (success, 502 when updater.check_update raises, 409 when lock held, 202 on dispatch, status passthrough). Co-Authored-By: Claude Opus 4.6 (1M context) --- furtka/api.py | 92 ++++++++++++++++++++ furtka/assets/www/settings/index.html | 116 ++++++++++++++++++++++++++ furtka/assets/www/style.css | 9 ++ tests/test_api.py | 99 ++++++++++++++++++++++ 4 files changed, 316 insertions(+) diff --git a/furtka/api.py b/furtka/api.py index bba984e..965e5e8 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -481,6 +481,87 @@ def _do_remove(name): return 200, {"removed": name, "compose_warning": compose_warning} +def _do_furtka_check(): + """Thin wrapper around updater.check_update for the /api/furtka/update/check endpoint.""" + from furtka import updater + + try: + check = updater.check_update() + except updater.UpdateError as e: + return 502, {"error": str(e)} + return 200, { + "current": check.current, + "latest": check.latest, + "update_available": check.update_available, + } + + +def _do_furtka_apply(): + """Kick off a Furtka self-update detached from this process. + + The API handler itself is about to be restarted (furtka-api.service is + part of what gets replaced), so we can't block the response on the + update completing. systemd-run starts the update as its own transient + unit — lifecycle independent of furtka-api, so the restart it triggers + mid-flight doesn't kill the updater. + + The client polls /update-state.json (served by Caddy, not the API) to + watch progress. Returns 202 on successful dispatch; 409 if an update + is already running; 502 on systemd-run failure. + """ + import subprocess + + from furtka import updater + + # Check the lockfile ahead of time. systemd-run would succeed even if a + # concurrent update is running — the child process would then fail loudly + # at acquire_lock(), but the 202 would already be out. Better to 409 up + # front. + try: + fh = updater.acquire_lock() + except updater.UpdateError as e: + return 409, {"error": str(e)} + # Release the lock so the detached child can acquire it. The race between + # our release and the child's acquire is harmless — worst case the child + # logs "already in progress" and we've already 202'd. + fh.close() + + try: + subprocess.run( + [ + "systemd-run", + "--unit=furtka-update", + "--no-block", + "--collect", + "/usr/local/bin/furtka", + "update", + ], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError: + return 502, {"error": "systemd-run not available"} + except subprocess.CalledProcessError as e: + return 502, { + "error": f"systemd-run failed: {(e.stderr or e.stdout or '').strip()}", + } + + return 202, {"status": "dispatched", "unit": "furtka-update"} + + +def _do_furtka_status(): + """Return the latest update-state.json written by the updater. + + Mirrors what Caddy serves at /update-state.json — duplicated here so the + API has a consistent endpoint surface. Returns {} if no update has ever + been attempted. + """ + from furtka import updater + + return 200, updater.read_state() + + def _do_update(name): """Pull newer container images for an installed app; restart if any changed. @@ -552,6 +633,9 @@ class _Handler(BaseHTTPRequestHandler): return self._json(200, _list_installed()) if self.path == "/api/bundled": return self._json(200, _list_bundled()) + if self.path == "/api/furtka/update/status": + status, body = _do_furtka_status() + return self._json(status, body) # /api/apps//settings if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): name = self.path[len("/api/apps/") : -len("/settings")] @@ -590,6 +674,14 @@ class _Handler(BaseHTTPRequestHandler): status, body = _do_update(name) return self._json(status, body) + # Furtka itself: check + apply endpoints (Phase 2 self-update). + if self.path == "/api/furtka/update/check": + status, body = _do_furtka_check() + return self._json(status, body) + if self.path == "/api/furtka/update/apply": + status, body = _do_furtka_apply() + 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/assets/www/settings/index.html b/furtka/assets/www/settings/index.html index e592765..1bb7ba3 100644 --- a/furtka/assets/www/settings/index.html +++ b/furtka/assets/www/settings/index.html @@ -35,6 +35,21 @@ +
+

Furtka updates

+
+
+
Installed
+
Latest available
+
+
+ + +
+

+
+
+

Appearance

@@ -76,12 +91,113 @@ document.getElementById('set-ram').textContent = s.ram_total || '—'; document.getElementById('set-docker').textContent = s.docker_version || '—'; document.getElementById('set-uptime').textContent = s.uptime || '—'; + document.getElementById('upd-current').textContent = s.furtka_version || '—'; } catch (e) { /* next tick will retry */ } } refresh(); setInterval(refresh, 15000); + + // --- Furtka updates ----------------------------------------------- + + const STAGE_LABELS = { + downloading: 'Downloading release…', + verifying: 'Verifying signature…', + extracting: 'Unpacking update…', + swapping: 'Switching to new version…', + restarting: 'Restarting services…', + done: 'Update complete — reloading…', + rolled_back: 'Update failed, rolled back to the previous version', + rolled_back_manual: 'Rolled back manually', + }; + + let pollHandle = null; + const statusEl = document.getElementById('update-status'); + const checkBtn = document.getElementById('check-updates-btn'); + const applyBtn = document.getElementById('apply-update-btn'); + + function setStatus(msg, isError = false) { + statusEl.textContent = msg; + statusEl.style.color = isError ? 'var(--danger)' : 'var(--muted)'; + } + + checkBtn.addEventListener('click', async () => { + checkBtn.disabled = true; + const original = checkBtn.textContent; + checkBtn.textContent = 'Checking…'; + setStatus(''); + try { + const r = await fetch('/api/furtka/update/check', { method: 'POST' }); + const data = await r.json(); + if (!r.ok) { + setStatus(data.error || `HTTP ${r.status}`, true); + return; + } + document.getElementById('upd-latest').textContent = data.latest || '—'; + if (data.update_available) { + applyBtn.hidden = false; + applyBtn.textContent = `Update to ${data.latest}`; + setStatus(`Update available: ${data.current} → ${data.latest}`); + } else { + applyBtn.hidden = true; + setStatus('Already up to date'); + } + } catch (e) { + setStatus(`Network error: ${e.message}`, true); + } finally { + checkBtn.disabled = false; + checkBtn.textContent = original; + } + }); + + applyBtn.addEventListener('click', async () => { + applyBtn.disabled = true; + checkBtn.disabled = true; + setStatus('Starting update…'); + try { + const r = await fetch('/api/furtka/update/apply', { method: 'POST' }); + const data = await r.json(); + if (r.status === 409) { + setStatus('Another update is already running — watching it', true); + } else if (!r.ok) { + setStatus(data.error || `HTTP ${r.status}`, true); + applyBtn.disabled = false; + checkBtn.disabled = false; + return; + } + // Poll /update-state.json (served by Caddy, unaffected by the + // API restart the updater is about to trigger) every 2s. + pollHandle = setInterval(pollUpdateState, 2000); + } catch (e) { + setStatus(`Network error: ${e.message}`, true); + applyBtn.disabled = false; + checkBtn.disabled = false; + } + }); + + async function pollUpdateState() { + try { + const r = await fetch('/update-state.json', { cache: 'no-store' }); + if (!r.ok) return; + const s = await r.json(); + const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`; + setStatus(label, s.stage === 'rolled_back'); + if (s.stage === 'done') { + clearInterval(pollHandle); + setTimeout(() => location.reload(), 5000); + } else if (s.stage === 'rolled_back') { + clearInterval(pollHandle); + if (s.reason) { + setStatus(`${label} — ${s.reason}`, true); + } + applyBtn.disabled = false; + checkBtn.disabled = false; + } + } catch (e) { + /* keep polling; restart blip expected */ + } + } diff --git a/furtka/assets/www/style.css b/furtka/assets/www/style.css index 3084f51..ff24426 100644 --- a/furtka/assets/www/style.css +++ b/furtka/assets/www/style.css @@ -304,6 +304,15 @@ details.log-details[open] > summary { color: var(--fg); } margin-top: 0.5rem; } +/* Row of buttons beneath a card — used by the Furtka updates card on + /settings. Left-aligned, wraps on narrow screens. */ +.update-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 1rem; +} + /* -- Shared primitives for later slices ------------------------ */ .chip { display: inline-block; diff --git a/tests/test_api.py b/tests/test_api.py index cfb902f..c3dcd7f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -429,6 +429,105 @@ def test_update_returns_502_on_pull_error(fake_dirs, no_docker, update_docker_st assert update_docker_stubs["up_called"] == 0 +# --- Furtka self-update endpoints ------------------------------------------ + + +@pytest.fixture +def stub_furtka_updater(monkeypatch): + """Stub the updater module so api endpoints don't hit Forgejo / systemd-run.""" + state = {"check_called": 0, "apply_called": 0, "status_called": 0} + + from furtka import updater + + class _Lock: + def close(self): + pass + + def stub_check(): + state["check_called"] += 1 + return updater.UpdateCheck( + current="26.0-alpha", + latest="26.1-alpha", + update_available=True, + tarball_url="https://x/t.tar.gz", + sha256_url="https://x/t.tar.gz.sha256", + ) + + def stub_acquire_lock(): + return _Lock() + + def stub_read_state(): + state["status_called"] += 1 + return {"stage": "done", "version": "26.1-alpha"} + + import subprocess + + def stub_subprocess_run(*args, **kwargs): + state["apply_called"] += 1 + + class _Result: + returncode = 0 + stdout = "" + stderr = "" + + return _Result() + + monkeypatch.setattr(updater, "check_update", stub_check) + monkeypatch.setattr(updater, "acquire_lock", stub_acquire_lock) + monkeypatch.setattr(updater, "read_state", stub_read_state) + monkeypatch.setattr(subprocess, "run", stub_subprocess_run) + return state + + +def test_furtka_update_check_endpoint(stub_furtka_updater): + status, body = api._do_furtka_check() + assert status == 200 + assert body == { + "current": "26.0-alpha", + "latest": "26.1-alpha", + "update_available": True, + } + assert stub_furtka_updater["check_called"] == 1 + + +def test_furtka_update_check_reports_updater_errors(monkeypatch): + from furtka import updater + + def raising(): + raise updater.UpdateError("no network") + + monkeypatch.setattr(updater, "check_update", raising) + status, body = api._do_furtka_check() + assert status == 502 + assert "no network" in body["error"] + + +def test_furtka_update_apply_endpoint_dispatches(stub_furtka_updater): + status, body = api._do_furtka_apply() + assert status == 202 + assert body["status"] == "dispatched" + assert stub_furtka_updater["apply_called"] == 1 + + +def test_furtka_update_apply_returns_409_if_locked(monkeypatch): + from furtka import updater + + def raising(): + raise updater.UpdateError("another update is already in progress") + + monkeypatch.setattr(updater, "acquire_lock", raising) + status, body = api._do_furtka_apply() + assert status == 409 + assert "in progress" in body["error"] + + +def test_furtka_update_status_endpoint(stub_furtka_updater): + status, body = api._do_furtka_status() + assert status == 200 + assert body == {"stage": "done", "version": "26.1-alpha"} + assert stub_furtka_updater["status_called"] == 1 + + def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs): _, bundled = fake_dirs _write_bundled(bundled, "fileshare", env_example="A=real")