feat(furtka): Furtka-updates card on /settings + API endpoints

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) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-16 13:44:34 +02:00
parent f0acc4427e
commit 5c58eade1c
4 changed files with 316 additions and 0 deletions

View file

@ -481,6 +481,87 @@ def _do_remove(name):
return 200, {"removed": name, "compose_warning": compose_warning} 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): def _do_update(name):
"""Pull newer container images for an installed app; restart if any changed. """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()) return self._json(200, _list_installed())
if self.path == "/api/bundled": if self.path == "/api/bundled":
return self._json(200, _list_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/<name>/settings # /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")] name = self.path[len("/api/apps/") : -len("/settings")]
@ -590,6 +674,14 @@ class _Handler(BaseHTTPRequestHandler):
status, body = _do_update(name) status, body = _do_update(name)
return self._json(status, body) 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") name = payload.get("name")
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"}) return self._json(400, {"error": "missing or empty 'name' field"})

View file

@ -35,6 +35,21 @@
</div> </div>
</section> </section>
<section>
<h2>Furtka updates</h2>
<div class="card">
<dl class="kv">
<dt>Installed</dt><dd id="upd-current"></dd>
<dt>Latest available</dt><dd id="upd-latest"></dd>
</dl>
<div class="update-actions">
<button id="check-updates-btn" class="secondary">Check for updates</button>
<button id="apply-update-btn" hidden>Update now</button>
</div>
<p id="update-status" class="hint"></p>
</div>
</section>
<section> <section>
<h2>Appearance</h2> <h2>Appearance</h2>
<div class="card"> <div class="card">
@ -76,12 +91,113 @@
document.getElementById('set-ram').textContent = s.ram_total || '—'; document.getElementById('set-ram').textContent = s.ram_total || '—';
document.getElementById('set-docker').textContent = s.docker_version || '—'; document.getElementById('set-docker').textContent = s.docker_version || '—';
document.getElementById('set-uptime').textContent = s.uptime || '—'; document.getElementById('set-uptime').textContent = s.uptime || '—';
document.getElementById('upd-current').textContent = s.furtka_version || '—';
} catch (e) { } catch (e) {
/* next tick will retry */ /* next tick will retry */
} }
} }
refresh(); refresh();
setInterval(refresh, 15000); 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 */
}
}
</script> </script>
</body> </body>
</html> </html>

View file

@ -304,6 +304,15 @@ details.log-details[open] > summary { color: var(--fg); }
margin-top: 0.5rem; 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 ------------------------ */ /* -- Shared primitives for later slices ------------------------ */
.chip { .chip {
display: inline-block; display: inline-block;

View file

@ -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 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): def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs):
_, bundled = fake_dirs _, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real") _write_bundled(bundled, "fileshare", env_example="A=real")