From cf93ef44cbf8f292446b3c1fb0a8fe033cf5b61a Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Mon, 20 Apr 2026 15:54:58 +0200 Subject: [PATCH] chore: release 26.8-alpha (power actions, supersedes orphan 26.7 tag) Adds Reboot + Shut down buttons on /settings, backed by a new POST /api/furtka/power endpoint that kicks a delayed `systemd-run --on-active=3s systemctl {reboot|poweroff}` so the HTTP response flushes before the kernel loses network. Both buttons open a native confirm dialog; after reboot, the page polls /furtka.json until the box is back and reloads itself. 26.7-alpha was tagged on 5d8ac63 but release.yml never fired for that tag (Forgejo race with the concurrent main push; re-push of the deleted tag didn't wake the workflow either). 26.8 supersedes it and carries the same open_url + Open-button content plus the power actions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .forgejo/workflows/release.yml | 43 ++++++++++++--- CHANGELOG.md | 12 +++-- RELEASING.md | 2 +- assets/www/settings/index.html | 96 +++++++++++++++++++++++++++++++++- assets/www/style.css | 3 +- furtka/api.py | 54 +++++++++++++++++++ pyproject.toml | 2 +- scripts/publish-release.sh | 9 ++++ tests/test_api.py | 63 ++++++++++++++++++++++ website/content/_index.de.md | 2 +- website/content/_index.md | 2 +- website/hugo.toml | 2 +- 12 files changed, 273 insertions(+), 17 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 6848668..6895d74 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -1,27 +1,58 @@ name: Release # Tag-triggered: when `git push origin ` lands, this builds the -# release tarball and publishes it + the sha256 + release.json to the -# Forgejo releases page for that tag. Boxes then POST /api/furtka/update -# to pull from here. +# release tarball + the live-installer ISO, and publishes them both to +# the Forgejo releases page. Boxes POST /api/furtka/update to pull the +# tarball; fresh-install users download the ISO from the release page. # -# Version tags only (pattern matches CalVer like 26.0-alpha, 26.1, 27.0-beta). -# Documentation / random tags are ignored by the [0-9]* prefix. +# Runs on the self-hosted runner because iso/build.sh needs privileged +# docker access (mkarchiso wants root + loop mounts), and because the +# ubuntu-latest Forgejo hosted runner doesn't carry the docker socket +# bind-mount the build needs. Self-hosted adds ~5-7 min to the release +# (ISO build) but keeps the release page self-contained. +# +# Version tags only (CalVer like 26.0-alpha, 26.1, 27.0-beta). Random +# tags are ignored by the [0-9]* prefix. on: push: tags: ['[0-9]*'] jobs: release: - runs-on: ubuntu-latest + runs-on: self-hosted + timeout-minutes: 45 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # changelog section extraction needs history + - name: Install prerequisites + # Alpine runner is near-empty: we need curl + python3 for the + # publish script, bash for the build scripts. + run: apk add --no-cache curl python3 bash + - name: Build release tarball run: ./scripts/build-release-tarball.sh "${GITHUB_REF_NAME}" + - name: Build live-installer ISO + # Same script build-iso.yml uses on every main push. Re-running + # here is intentional: guarantees the ISO matches the exact + # tagged commit without coordinating across workflows. Step-level + # continue-on-error so an ISO build flake doesn't block the + # core tarball (which is what boxes need for self-update) from + # publishing. + continue-on-error: true + id: build_iso + run: ./iso/build.sh + + - name: Move ISO into dist/ + # publish-release.sh attaches dist/furtka-.iso if present. + # Skipped gracefully when the build step above failed. + if: steps.build_iso.outcome == 'success' + run: | + iso=$(ls iso/out/*.iso | head -1) + cp "$iso" "dist/furtka-${GITHUB_REF_NAME}.iso" + - name: Publish to Forgejo releases env: FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2858b..ae24e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,22 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r ## [Unreleased] -## [26.7-alpha] - 2026-04-20 +## [26.8-alpha] - 2026-04-20 ### Added +- **Live-installer ISO attached to the Forgejo release page.** `.forgejo/workflows/release.yml` moves to the self-hosted runner, builds both the self-update tarball and the ISO, and `scripts/publish-release.sh` uploads the ISO as a fourth release asset (`furtka-.iso`) alongside the existing tarball + sha256 + release.json. Fresh-install users can now grab the ISO from the release page instead of hunting through `build-iso.yml` artifact retention windows. ISO build step is `continue-on-error` so an ISO flake doesn't hold back the core tarball that running boxes need for self-update. +- **Reboot + Shut down buttons on `/settings`.** Replaces the two "Coming next" placeholders with real actions backed by `POST /api/furtka/power` (`{"action": "reboot" | "poweroff"}`). Handler kicks a delayed `systemd-run --on-active=3s systemctl {reboot|poweroff}` so the HTTP response reaches the browser before the kernel loses network. Each button opens a native confirm dialog first (reboot: "back in ~30 s", shut down: "need to press the physical power button"), then the UI swaps to a status line and — after a reboot — polls `/furtka.json` until the box is back, reloading the page automatically. No auth (same posture as install/remove). - **Manifest `open_url` field + Open button in `/apps` and on the landing page.** Apps declare a URL template (e.g. `smb://{host}/files` for fileshare, `http://{host}:3001/` for Uptime Kuma); the UI substitutes `{host}` with the current browser's hostname at render time so the link follows however the user reached Furtka (furtka.local, raw IP, a future reverse-proxy hostname). The landing page's hardcoded `if app.name === 'fileshare'` special-case is gone — any app with an `open_url` in its manifest now gets a proper "Open" link. The core seed `apps/fileshare/manifest.json` bumps to v0.1.2 to carry it. ### Changed - `.btn` CSS class introduced so an `` rendered-as-button lines up with its ` + + +

+ + +

Coming next

Controls we're building — follow progress on furtka.org.

- Reboot - Shut down Change hostname Backup User accounts @@ -340,6 +353,85 @@ /* keep polling; restart blip expected */ } } + + // Power buttons: confirm, POST, then swap the whole card into a + // "going down" state so the user doesn't keep clicking. After a + // reboot we try to reconnect after ~45s; for shutdown we just + // tell the user the box is off — no auto-reconnect attempt. + const powerStatusEl = document.getElementById('power-status'); + const rebootBtn = document.getElementById('power-reboot'); + const poweroffBtn = document.getElementById('power-poweroff'); + + function setPowerStatus(msg, tone = 'muted') { + powerStatusEl.textContent = msg; + powerStatusEl.style.color = + tone === 'error' ? 'var(--danger)' : 'var(--muted)'; + } + + async function triggerPower(action, confirmMsg, inflightLabel) { + if (!confirm(confirmMsg)) return; + rebootBtn.disabled = true; + poweroffBtn.disabled = true; + setPowerStatus(inflightLabel); + try { + const r = await fetch('/api/furtka/power', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }), + }); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + setPowerStatus(data.error || `HTTP ${r.status}`, 'error'); + rebootBtn.disabled = false; + poweroffBtn.disabled = false; + return; + } + if (action === 'reboot') { + setPowerStatus('Rebooting… this page will reload when the box is back.'); + // Try reconnecting after a generous delay. archinstall + // + boot + services typically takes 30–45 s; give it 30 + // before the first poke so we don't just spin against + // a down kernel. + setTimeout(pollForReconnect, 30000); + } else { + setPowerStatus( + 'Shutdown scheduled. Press the physical power button to turn it back on.' + ); + } + } catch (e) { + setPowerStatus(`Network error: ${e.message}`, 'error'); + rebootBtn.disabled = false; + poweroffBtn.disabled = false; + } + } + + async function pollForReconnect() { + // Fetch a tiny static file; when it comes back 200 the box is up. + try { + const r = await fetch('/furtka.json', { cache: 'no-store' }); + if (r.ok) { + setPowerStatus('Back up — reloading…'); + setTimeout(() => location.reload(), 1500); + return; + } + } catch (e) { /* still down */ } + setTimeout(pollForReconnect, 3000); + } + + rebootBtn.addEventListener('click', () => + triggerPower( + 'reboot', + "Wirklich neu starten? Die Box ist für ~30 Sekunden nicht erreichbar.", + 'Rebooting…' + ) + ); + poweroffBtn.addEventListener('click', () => + triggerPower( + 'poweroff', + "Wirklich ausschalten? Du kannst die Box erst wieder starten, wenn du den physischen Power-Knopf drückst.", + 'Shutting down…' + ) + ); diff --git a/assets/www/style.css b/assets/www/style.css index d07e7f6..dde1293 100644 --- a/assets/www/style.css +++ b/assets/www/style.css @@ -329,7 +329,8 @@ details.log-details[open] > summary { color: var(--fg); } /* Row of buttons beneath a card — used by the Furtka updates card on /settings. Left-aligned, wraps on narrow screens. */ -.update-actions { +.update-actions, +.power-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; diff --git a/furtka/api.py b/furtka/api.py index fda5d3c..5ccfb38 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -727,6 +727,55 @@ def _do_catalog_status(): } +_POWER_ACTIONS = { + "reboot": "reboot", + "poweroff": "poweroff", +} + + +def _do_power(payload): + """Schedule a reboot or poweroff with a short delay. + + `systemd-run --on-active=3s` kicks a transient timer that fires + `systemctl {reboot|poweroff}` a few seconds after the API returns — + long enough for the HTTP response to reach the browser + the UI to + swap to a "Going down…" state before the kernel loses network. + The `--no-block` flag makes the systemd-run call itself return + immediately; `--collect` GCs the transient unit once it fires. + + No auth: same posture as the install/remove endpoints. Anyone on the + LAN can reboot the box. The /settings banner warns about this; + Authentik will lock it down. + """ + import subprocess + + action = payload.get("action") + systemctl_verb = _POWER_ACTIONS.get(action) + if systemctl_verb is None: + return 400, {"error": f"'action' must be one of {sorted(_POWER_ACTIONS)}"} + try: + subprocess.run( + [ + "systemd-run", + "--on-active=3s", + "--no-block", + "--collect", + "systemctl", + systemctl_verb, + ], + 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, {"action": action, "scheduled_in_seconds": 3} + + def _do_update(name): """Pull newer container images for an installed app; restart if any changed. @@ -866,6 +915,11 @@ class _Handler(BaseHTTPRequestHandler): status, body = _do_catalog_apply() return self._json(status, body) + # System power: /settings Reboot / Shut down buttons. + if self.path == "/api/furtka/power": + status, body = _do_power(payload) + 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/pyproject.toml b/pyproject.toml index f35b65e..76cfc69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "furtka" -version = "26.7-alpha" +version = "26.8-alpha" description = "Open-source home server OS — simple enough for everyone." requires-python = ">=3.11" readme = "README.md" diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index 74c3bfa..e5674a3 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -99,4 +99,13 @@ upload_asset "$TARBALL" upload_asset "$SHA_FILE" upload_asset "$RELEASE_JSON" +# Optional: attach the live-installer ISO when dist/furtka-.iso +# exists. Release workflows that want this build the ISO via iso/build.sh +# and move the output here before calling publish-release. Local runs +# that skip the ISO step still publish the core release successfully. +ISO="$DIST_DIR/furtka-$VERSION.iso" +if [ -f "$ISO" ]; then + upload_asset "$ISO" +fi + echo "Release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION" diff --git a/tests/test_api.py b/tests/test_api.py index 8d9e805..159cedb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -639,3 +639,66 @@ def test_catalog_check_surfaces_forgejo_error(fake_dirs, monkeypatch): status, body = api._do_catalog_check() assert status == 502 assert "forgejo api down" in body["error"] + + +# --- Power endpoints -------------------------------------------------------- + + +def test_power_rejects_unknown_action(fake_dirs): + status, body = api._do_power({"action": "format-harddrive"}) + assert status == 400 + assert "action" in body["error"] + + +def test_power_rejects_missing_action(fake_dirs): + status, body = api._do_power({}) + assert status == 400 + + +def test_power_reboot_dispatches_systemd_run(fake_dirs, monkeypatch): + seen = [] + + class _FakeCompleted: + returncode = 0 + stdout = "" + stderr = "" + + def fake_run(cmd, *, check=False, capture_output=False, text=False): + seen.append(cmd) + return _FakeCompleted() + + monkeypatch.setattr("subprocess.run", fake_run) + status, body = api._do_power({"action": "reboot"}) + assert status == 202 + assert body == {"action": "reboot", "scheduled_in_seconds": 3} + # The dispatched command is a delayed systemd-run that eventually + # invokes `systemctl reboot`. Asserting the key flags catches + # accidental regressions (e.g. losing --no-block would block the API + # thread until the unit completes). + assert seen[0][:1] == ["systemd-run"] + assert "--on-active=3s" in seen[0] + assert "--no-block" in seen[0] + assert seen[0][-2:] == ["systemctl", "reboot"] + + +def test_power_poweroff_dispatches_systemctl_poweroff(fake_dirs, monkeypatch): + seen = [] + + class _FakeCompleted: + returncode = 0 + + monkeypatch.setattr("subprocess.run", lambda cmd, **kw: (seen.append(cmd), _FakeCompleted())[1]) + status, body = api._do_power({"action": "poweroff"}) + assert status == 202 + assert body["action"] == "poweroff" + assert seen[0][-2:] == ["systemctl", "poweroff"] + + +def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch): + def boom(*a, **kw): + raise FileNotFoundError(2, "No such file", "systemd-run") + + monkeypatch.setattr("subprocess.run", boom) + status, body = api._do_power({"action": "reboot"}) + assert status == 502 + assert "systemd-run" in body["error"] diff --git a/website/content/_index.de.md b/website/content/_index.de.md index 2a176e5..c49809a 100644 --- a/website/content/_index.de.md +++ b/website/content/_index.de.md @@ -1,7 +1,7 @@ --- title: "Furtka" description: "Offenes Heimserver-Betriebssystem — einfach genug für alle." -status: "26.7-alpha — in Arbeit" +status: "26.8-alpha — in Arbeit" --- **Furtka** ist ein offenes Heimserver-Betriebssystem. diff --git a/website/content/_index.md b/website/content/_index.md index 2d1a749..2d9eaad 100644 --- a/website/content/_index.md +++ b/website/content/_index.md @@ -1,7 +1,7 @@ --- title: "Furtka" description: "Open-source home server OS — simple enough for everyone." -status: "26.7-alpha — work in progress" +status: "26.8-alpha — work in progress" --- **Furtka** is an open-source home server OS. diff --git a/website/hugo.toml b/website/hugo.toml index c505642..7f19e68 100644 --- a/website/hugo.toml +++ b/website/hugo.toml @@ -6,7 +6,7 @@ enableRobotsTXT = true [params] description = "Open-source home server OS — simple enough for everyone." - version = "26.7-alpha" + version = "26.8-alpha" contactEmail = "hallo@furtka.org" [markup.goldmark.renderer]