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:
parent
f0acc4427e
commit
5c58eade1c
4 changed files with 316 additions and 0 deletions
|
|
@ -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/<name>/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"})
|
||||
|
|
|
|||
|
|
@ -35,6 +35,21 @@
|
|||
</div>
|
||||
</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>
|
||||
<h2>Appearance</h2>
|
||||
<div class="card">
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue