Closes#9. New apps/README.md walks through the four-file contract
(manifest.json, docker-compose.yaml, .env.example, icon.svg) with
the rules enforced by furtka/manifest.py and the SVG sanitiser, using
apps/fileshare as the reference.
Root README: release list now covers 26.1/26.3/26.4 (26.2 stalled on
the jq apt hang). Local HTTPS Phase 1 and the post-build smoke VM on
pollux both flip to [x]; the old proksi.local HTTPS TODO becomes a
Phase 2 entry (dedicated local CA + HTTPS on the live-installer wizard).
iso/README: mDNS is wired — live ISO advertises proksi.local, installed
box defaults to furtka.local (the form's default hostname, not proksi).
HTTPS section notes Caddy tls internal on :443 shipped in 26.4 while
the wizard itself is still HTTP. Overlay table picks up etc/hostname,
etc/issue, furtka-update-issue, and furtka-issue.service.
website/README: auto-deploy via .forgejo/workflows/deploy-site.yml is
the default path now; website/deploy.sh stays as the SSH-hop fallback
for off-CI pushes, and deploy-ci.sh is called out in the structure map.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When host-networking finally gave arp-scan a real LAN to scan, the
first MAC-match emitted a line, awk hit its `exit` clause, closed the
pipe, and arp-scan died from SIGPIPE (exit 141). With `set -o pipefail`
active, that killed the whole smoke-vm.sh run immediately after
"==> starting VM" — no IP discovery, no curl, no prune.
Fix: capture arp-scan's output into a variable first, then let awk
parse a here-string. Same treatment for the `ip neigh show` fallback.
No pipe, no pipefail cascade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pollux (192.168.178.165) wedged at the network level during an
end-to-end install test today — mkinitcpio on a 4 GiB smoke VM +
the cached 1.5 GB ISO + a busy runner container pushed the host into
OOM, taking pveproxy and the SSH path down with it. Recovered by
physical reset.
Smoke VM now defaults to 8192 MiB / 2 vCPU, configurable via
PVE_TEST_VM_MEMORY / PVE_TEST_VM_CORES. Host has 64 GiB, so one
smoke VM at 8 GiB is well within headroom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps version everywhere user-facing that had drifted from the tag:
- pyproject.toml 26.0 → 26.4
- website/hugo.toml 26.0 → 26.4 (driving furtka.org landing + footer)
- website/content/_index{.md,.de.md} status string
- webinstaller/templates/base.html footer (was hardcoded — noted as
follow-up to read dynamically from pyproject.toml)
Promotes the Unreleased section to 26.4-alpha and folds in today's
additions:
- Local HTTPS via Caddy tls internal + opt-in redirect toggle
- Two self-update UX fixes (Installed-field refresh + 45s reload
fallback)
- Impressum + Datenschutzerklärung on furtka.org
- deploy-site.yml auto-deploy of the Hugo site on push-to-main
- Smoke VM pipeline on .165 Proxmox (build-iso inline smoke step +
workflow_dispatch Smoke latest ISO for cheap re-tests)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
api() was swallowing Proxmox's error body because callers pipe its
output to /dev/null. With a bare "curl: (22) 403" in the log we can't
tell which permission is missing. Now we capture the response body,
print it to stderr on failure, and only emit it to stdout on success.
No behaviour change on the happy path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The delete branch required Datastore.Allocate (or was hitting a
privilege-separated token ACL edge case) and produced 403s on re-runs
against the same commit SHA. Since the ISO bytes are reproducible for
a given SHA — furtka-<sha>.iso is content-addressed — we can just
reuse whatever is already in PVE storage instead of cycling it.
Fixes the "runs-on-same-sha" re-dispatch case without needing any extra
PVE permission, and shaves ~2 min off repeated smoke runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Forgejo runner container is Alpine with a near-empty base — no
curl, python3, arp-scan, or sudo out of the box. scripts/smoke-vm.sh
needs all four:
- curl: every PVE API call
- python3: JSON parsing of PVE responses
- arp-scan: MAC→IP discovery on the LAN (live ISO has no guest agent)
- sudo: so the same script also works from a dev laptop as non-root
Without this step the smoke job fails immediately on "curl: not found",
regardless of whether the PVE secrets are correctly set.
Added to both build-iso.yml (inline smoke after ISO build) and
smoke-latest.yml (workflow_dispatch retest path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When smoke-vm.sh / PVE setup / secrets change, we want to verify the
fix without waiting for a full 25-min build-iso rebuild (most of which
is the upload-artifact step for a 1.5 GB file).
Adds two things:
1. build-iso.yml grows a "Cache ISO for smoke-latest" step that copies
the freshly built ISO to /data/smoke-cache/latest.iso. /data is
already bind-mounted into the runner container at a matching host
path, so no compose.yml change or runner restart needed.
2. smoke-latest.yml is a workflow_dispatch-only workflow that reads
/data/smoke-cache/latest.iso and runs scripts/smoke-vm.sh against
it. ~2 min end-to-end. Errors cleanly if the cache is empty (build-
iso.yml hasn't populated it yet).
First build-iso run after this merges will populate the cache; from
then on smoke-latest is available for on-demand re-tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The separate smoke-vm job with `needs: build-iso` required round-tripping
the 1.5 GB ISO through actions/upload-artifact + download-artifact. v3
on Forgejo has a known issue where large artifacts stall at 0.0% in the
download step — the smoke run hung today with endless "Total file count:
1 ---- Processed file #0 (0.0%)" output.
Since both jobs run on the same self-hosted runner (host mode, same
workspace available), there was never a real need for the artifact
indirection. Inlining as a step after the artifact upload reuses the
ISO already in iso/out/ and skips the download entirely.
step-level continue-on-error preserves the original guarantee that a
VM-side flake doesn't mark the ISO build red.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coupled changes that make sense to land together:
1. Legal pages required under German law
- /imprint/ + /de/impressum/ — §5 DDG disclosure (contact is email
plus Forgejo-Issues as the second quick-contact channel, per ECJ
C-298/07 no phone number required)
- /privacy/ + /de/datenschutz/ — Art. 13 GDPR minimum: server-log
processing (IP, UA, URL, retention ≤30 days), no cookies, no
tracking, no third-party embeds. RLP Landesbeauftragter as the
competent supervisory authority.
- Footer partial linked from every page, localized per language.
- DE versions are legally binding; EN versions are courtesy
translations noting that.
2. Auto-deploy wired up
- New workflow .forgejo/workflows/deploy-site.yml fires on
push-to-main with paths under website/**. Runs on the self-hosted
runner, which *is* forge-runner-01 — so "deploy" is just a local
rsync into /srv/furtka-site and a hugo build into
/var/www/furtka.org. No SSH, no secrets.
- website/deploy-ci.sh is the SSH-free counterpart of deploy.sh,
invoked by the workflow.
- compose.yml bind-mounts /srv/furtka-site and /var/www/furtka.org
into the runner container at matching paths so the workflow can
reach them. Requires a one-time `docker compose up -d` on the
runner host to pick the mounts up.
- deploy.sh is kept for out-of-band manual deploys (testing from a
local branch, CI outage) but gets a header comment pointing at
the CI path as the normal flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After build-iso, a new smoke-vm job uploads the freshly built ISO to
the test Proxmox at 192.168.178.165 via PVE API token, boots it in a
fresh VM (VMID range 9000-9099, MAC derived from commit SHA so the
runner can find the DHCP IP by scanning the LAN), and curls :5000 to
confirm the webinstaller answers HTTP 200. Last 5 smoke VMs + their
ISOs are kept for post-mortem; older ones are purged. continue-on-error
on the smoke job so a VM-side flake doesn't mark the ISO build red.
Shortens the feedback loop on ISO regressions from "next manual VM
test session" (days) to "next push" (minutes) — the 2026-04-15/16 VM
sessions each found real boot-time bugs that unit tests missed.
Docs at docs/smoke-vm.md. Requires Forgejo secrets PVE_TEST_HOST and
PVE_TEST_TOKEN (dedicated smoke@pve!ci PVE token, privilege-separated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Caddy now serves both :80 (plain HTTP, unchanged default) and :443 with
tls internal — it generates its own per-box root CA on first start,
stored under /var/lib/caddy/.local/share/caddy/pki/authorities/local/.
Users can download rootCA.crt at /rootCA.crt (served on both listeners)
and install it per-OS via the new /https-install/ guide.
Settings page grows a Local HTTPS card with CA fingerprint, download
button, reachability probe, and an opt-in "force HTTPS" toggle. The
toggle only unhides itself once the current browser already trusts the
cert, so enabling it can't lock the user out of the settings page.
Backend: GET /api/furtka/https/status and POST /api/furtka/https/force
in furtka.https. The force toggle drops a Caddy import snippet into
/etc/caddy/furtka.d/redirect.caddyfile and reloads Caddy; reload
failure rolls the snippet state back so a bad config can't wedge the
next service start.
updater._refresh_caddyfile() ensures /etc/caddy/furtka.d exists before
every reload so 26.3-alpha → 26.4-alpha self-updates don't trip on the
new glob import directive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drive upd-current from the /api/furtka/update/check response so a
post-update Check reflects the new installed version without Ctrl+F5,
and arm a 45s fallback location.reload on apply-click so the page still
comes up on the new version when the mid-apply API restart drops the
/update-state.json poll before stage=done is observed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end validation of the per-app container update and the Furtka
self-update ran green on VM 192.168.178.128 this afternoon (26.0-alpha
→ 26.3-alpha → rollback → reboot). Both flows are real — promote the
drafted HTML-comment bullets from _index.md and _index.de.md into the
visible "What works today" list.
The "plain-English Wi-Fi story" was the only one the copy was missing
a truthful on-box outcome for, and it still is (for a moment here
a-few-days-ago, but the update story moved past that).
Matches the commitment in feedback_no_invented_content.md — we only
publish after confirmation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Roadmap section drifted far enough that "re-tag 26.0-alpha" was still
listed as open while 26.1-alpha and 26.3-alpha are live releases.
Updated:
- Replaced the stale "re-tag 26.0-alpha" line with the actual state:
tag-driven release pipeline is wired, two pre-releases published,
all assets downloadable anonymously.
- Added five new checked items for the work that landed this month:
resource manager + fileshare (validated), on-box UI uplevel (shared
CSS / settings page / icons), versioned layout + per-app container
updates, Phase 2 Furtka self-update (tag → release.yml → /settings
Update now → atomic swap + auto-rollback), plus the broader Forgejo
release pipeline that underpins the update story.
- Kept open items (wizard S3-S7, managed gateway, Authentik, local CA,
Nextcloud first service, UI mockups) as the remaining TODO surface.
No code or test changes; pytest + ruff still green from the last push.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
End-to-end validated the Phase-2 self-update today on a fresh install
(192.168.178.128 → 26.0-alpha → 26.3-alpha): the symlink flip, the
tarball verify, the stage-by-stage progress, and the rollback slots
all work. But two browser-side UX bits are rough:
1. The "Installed" version displayed on /settings doesn't refresh
right after the update; a hard reload shows the new value.
2. The auto-reload that should fire 5s after stage=done missed on
the test — the polling connection likely dropped during the
mid-update API restart.
Neither affects the integrity of the update itself. Landed the notes
in [Unreleased] so the next release cycle picks them up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The 26.2-alpha release workflow hung for 15+ minutes on
"apt-get install -y jq" — the runner's apt mirror was unreachable
(or very slow), and the whole publish stalled.
jq was only used for two tiny things: building the release-create
POST body and reading the release id from the response. Both are
one-liners in Python, which is guaranteed-present on the Forgejo
Actions ubuntu-latest runner image. Replaced both uses; removed
the apt-get step from release.yml entirely. Slow mirrors no
longer block tagged releases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Forgejo's /releases/latest silently skips pre-releases (any release
with a -alpha / -beta / -rc suffix) and 404s when there's no stable
release. During Furtka's alpha stage every tag is a pre-release, so
the Check-for-updates button always 404'd against a perfectly-valid
releases page.
Switch check_update() to GET /releases?limit=1 and take the first
entry. Forgejo returns releases newest-first regardless of kind, so
this works whether the top of the list is pre-release or stable.
Empty list (no releases published yet) now returns a clean
"no releases" UpdateError instead of a raw 404.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Third install-path miss from the versioned-layout refactor: the bundled
apps moved from /opt/furtka/apps (flat) to
/opt/furtka/versions/<ver>/apps/, reached through the /opt/furtka/current
symlink. paths.py was still pointing at the flat path, so _list_bundled
walked a non-existent directory and /api/bundled returned [] — the
fileshare tile never showed up on /apps.
Tests already use FURTKA_BUNDLED_APPS_DIR env override so nothing in
the suite needed to change. Confirmed on the VM: compat symlink
/opt/furtka/apps -> current/apps makes /api/bundled return the
fileshare manifest immediately, no service restart needed since
scanner.py reads the directory on every request.
Locking the path at current/apps rather than leaving the flat fallback
is deliberate — Phase-2 self-updates flip the symlink, and a flat
/opt/furtka/apps wouldn't move with them; bundled apps would freeze
at whatever version installed first.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two install-path bugs surfaced by SSHing into the hot-fixed test VM:
1. mktemp creates the staging dir with mode 700 by default; the rename
to /opt/furtka/versions/<ver>/ preserved it, and Caddy (running as
the unprivileged `caddy` user) got 403 Forbidden because it couldn't
traverse the version dir. Now the install + self-update both
`chmod 755` after the rename.
2. _furtka_json_cmd was a silent no-op on the 43a39a4 VM — the
base64-encoded body + sed substitution approach layered two sets of
quotes through archinstall's custom_commands eval, and the sed
step either never ran or didn't match. Replaced with a plain
heredoc that interpolates $(date -Iseconds) and $(cat VERSION) at
chroot runtime. Result lands /var/lib/furtka/furtka.json reliably,
which is what the landing page's hostname chip and the settings
page's install-date field depend on.
Both issues confirmed fixed by applying them manually on the VM
(chmod 755 /opt/furtka/versions/26.0-alpha + writing furtka.json by
hand): landing page, /apps, /settings, /furtka.json all now 200 with
correct content.
Tests updated (the chmod 755 gets asserted; the old base64+sed test
gets replaced with a heredoc-shape check; the updater test asserts
0o755 mode on the finished version dir).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of today's 403 on a fresh install: assets/ lived inside the
Python package at furtka/assets/, so the resource-manager tarball
extracted to /opt/furtka/versions/<ver>/furtka/assets/. But Caddyfile
has `root * /opt/furtka/current/assets/www`, systemd units point at
/opt/furtka/current/assets/bin/furtka-status, and the install-time
`systemctl link /opt/furtka/current/assets/systemd/*.service` expected
the top-level layout. All three found nothing:
- Caddy → 403 Forbidden (empty/missing document root)
- systemctl link → silent no-op, nothing ever linked into
/etc/systemd/system/
- furtka-api.service + furtka-reconcile.service → "inactive" because
they were never registered
Nothing in the Python package ever imported furtka.assets — these are
shell scripts, HTML/CSS, systemd units, and a Caddyfile, which is
config data, not package data. Promoting assets/ to the repo root
matches how it's referenced everywhere downstream and eliminates the
path mismatch.
Changes:
- git mv furtka/assets assets
- iso/build.sh: tarball-staging step now also `cp -a "$REPO_ROOT/assets"`
so the tarball ships ./assets at its root, and the live-ISO copy
reads from $REPO_ROOT/assets instead of $REPO_ROOT/furtka/assets.
- scripts/build-release-tarball.sh: same for release tarballs.
- webinstaller/app.py: _resolve_assets_dir's dev fallback walks one
level up to REPO_ROOT/assets/.
- tests/test_webinstaller_assets.py: ASSETS constant updated.
Tests still green (150/150) because both paths were fs-level — no
code imports changed. Next ISO build will land assets at the path
everything downstream expects.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Python 3.14 added pathlib.Path.copy() which refuses source==target with
OSError [Errno 22]. archinstall's sync_log_to_install_medium() calls
.copy() on install.log to itself at __exit__ time, because by then the
chroot mountpoint is already torn down and both source and target
resolve to the same /var/log/archinstall/install.log. The install
itself has already succeeded — the crash is in the log-sync cleanup.
Patch is a pre-start sed on the live ISO that replaces the offending
call with `None` (a no-op expression-statement keeping the same
indent level). Lives on furtka-webinstaller.service as ExecStartPre
so it runs before the first install attempt; idempotent, so service
restarts don't re-trigger anything. Never touches the installed
system — only the live ISO's site-packages tree.
Real fix is upstream in archinstall (guard the copy when source
and target resolve equal); this is a workaround until they ship it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Held as HTML comments in both _index.md and _index.de.md so the next
site update — after the next ISO test confirms the container-image
update flow and the Furtka self-update flow on real hardware — is
just stripping the comment markers, not rewriting the copy from
scratch.
Keeping the live site honest until we can say "this works": the
Phase-1 per-app updater and the Phase-2 self-update pipeline are both
in the tree and in CI, but they haven't booted from an ISO yet. Per
the "only publish facts verifiable from repo or confirmed by user"
convention, they stay off the public page until that test is green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
archinstall's enum members got renamed from CapitalCase (Ext4) to
ALL_CAPS (EXT4) between when build_disk_config was written and the
version baked into the current Arch live ISO. Result: the install
step fires AttributeError at the archinstall JSON-dump moment,
rendering a Flask 500 right after the Confirm page.
Traceback from the VM:
File "/opt/furtka/app.py", line 128, in build_disk_config
filesystem_type=FilesystemType.Ext4,
AttributeError: type object 'FilesystemType' has no attribute
'Ext4'. Did you mean: 'EXT4'?
Tests pass because build_disk_config is monkeypatched out in CI
(archinstall isn't pip-installable; it only exists on the live ISO).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Five issues surfaced by the Phase-2 audit before the next ISO rebuild:
P1 (real blockers for a fresh install / self-update):
1. chmod +x furtka/assets/bin/furtka-status, furtka-welcome. They were
mode 644 in git, so the tarball shipped them non-executable and every
ExecStart referencing /opt/furtka/current/assets/bin/furtka-* would
have failed on first boot with Permission denied.
2. apply_update now refreshes /etc/caddy/Caddyfile from the new version
when the content differs, then reloads caddy. Without this, a release
that changes Caddy routes silently stays on the old config.
3. apply_update now systemctl-links any new unit files shipped by the
update, not just the five linked at install time. A future release
that adds furtka-foo.service would otherwise never appear in
/etc/systemd/system/.
P2 (hardening, not blockers today):
4. _resource_manager_commands now aborts the install if the tarball's
VERSION file is empty — otherwise `mv "$staging" /opt/furtka/versions/`
would move the staging dir in as a subdirectory and the symlink
target would be invalid.
5. _extract_tarball passes filter='data' to tarfile.extractall on
Python 3.12+ to catch symlink-escape / setuid / device-node tricks
that the regex path-check can't see. Falls back silently on older
interpreters.
Plus the CHANGELOG [Unreleased] section got filled in with the whole
Phase-1 + Phase-2 + UI-uplevel body so a 26.1-alpha tag cut off main
has meaningful release notes.
Test additions / updates:
- test_refresh_caddyfile_{copies_when_different,noops_if_source_missing}
- test_link_new_units_only_links_missing
- test_extract_tarball_uses_data_filter_when_available
- test_apply_update_happy_path now verifies the Caddyfile gets copied.
- test_resource_manager_extracts_to_versioned_slot verifies the
empty-VERSION guard is present in the install command.
Paths now overridable via FURTKA_CADDYFILE_PATH + FURTKA_SYSTEMD_DIR so
tests can pin a tmpdir for these new fs operations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Slice 2 of the self-update story. Tagging a release on main now
produces a downloadable self-update payload on the Forgejo releases
page, and a running box can pull it down, verify it, atomically swap
to the new version, and health-check the result.
New pieces:
- scripts/build-release-tarball.sh <version> — packages the furtka/
package + bundled apps/ + a root-level VERSION file as
dist/furtka-<version>.tar.gz, plus a .sha256 sidecar and a
release.json metadata blob.
- scripts/publish-release.sh <version> — uses the Forgejo v1 API to
create a release (body pulled from the CHANGELOG section for this
tag, pre-release auto-flagged on -alpha/-beta/-rc) and upload the
three assets sequentially. Needs \$FORGEJO_TOKEN.
- .forgejo/workflows/release.yml — tag-triggered, runs both scripts
with the new \$FORGEJO_RELEASE_TOKEN repo secret.
- furtka/updater.py — check_update, prepare_update, apply_update,
run_update, rollback. Atomic symlink swap, sha256 verify (TOCTOU-
safe: re-hashes on-disk file), health-check post-restart with
auto-rollback on failure, stage-by-stage progress persisted to
/var/lib/furtka/update-state.json so the UI can poll independent
of the (restarting) API process. Path overrides via FURTKA_ROOT /
FURTKA_STATE_DIR / FURTKA_LOCK_PATH so tests pin a tmpdir.
- furtka/cli.py — \`furtka update [--check] [--json]\` and
\`furtka rollback\`.
- tests/test_updater.py — 15 tests: version compare, sha256 verify,
tarball extract (including traversal refusal), lockfile, apply
happy + rollback paths, rollback CLI, check_update with stubbed
Forgejo.
- iso/build.sh — writes VERSION at the tarball root so the install
path matches the self-update path (previously assumed only the
release script did this).
RELEASING.md now points at the automated flow — no more manually
clicking "Create release" on the Forgejo UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slice 1b of the self-update story. The installer now sets up a versioned
layout — install extracts the resource-manager tarball to a staging dir,
reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/,
and creates /opt/furtka/current as a symlink pointing at it. All runtime
references (Caddy, wrapper, systemd ExecStart) go through /current, so
Phase 2's self-update just flips the symlink atomically.
Systemd units move from hand-written files in /etc/systemd/system/ to
`systemctl link /opt/furtka/current/assets/systemd/*` — one link per
unit, stable across upgrades because the link target is /current. The
furtka-status + furtka-welcome units now ExecStart the shipped scripts
directly from /opt/furtka/current/assets/bin/, which means we no longer
copy those scripts to /usr/local/bin/ at install time.
Runtime JSON (status.json, furtka.json, update-state.json) moves to
/var/lib/furtka/ so self-updates never clobber it. Caddy serves those
three paths from there; everything else from /opt/furtka/current/assets/www/.
The __HOSTNAME__ sed-template hack is gone. At install time we write
/var/lib/furtka/furtka.json with {hostname, install_date, version}, and
the landing page's JS reads it on load to populate the hostname chip
and to build the SMB deep-link for the fileshare tile. First paint gets
a "—" placeholder and hydrates once fetch completes.
Test updates:
- test_webinstaller_assets enforces the new command shape (extract-to-
staging, ln -sfn /opt/furtka/current, systemctl link per unit,
no writes to /srv/furtka/www/).
- test_app's legacy "payload present" / "payload absent" tests match
the new layout too.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd-
unit payload that used to live as a triple-quoted string constant inside
webinstaller/app.py now lives as a real file under furtka/assets/:
furtka/assets/Caddyfile
furtka/assets/VERSION (new — matches pyproject.toml)
furtka/assets/www/{index.html, settings/index.html, style.css, status.json}
furtka/assets/bin/{furtka-status, furtka-welcome}
furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service
furtka/assets/systemd/furtka-status.timer
The installer now pulls each file from disk via _read_asset(). Byte-for-
byte identical output at install time — a fresh-ISO install should land
the same files in the same places with the same contents, verified by
tests/test_webinstaller_assets.py which reconstructs each base64 blob
and asserts equality against the on-disk asset.
iso/build.sh also copies furtka/assets/ next to the webinstaller source
at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds
them with a "next to me" lookup. In dev the same function walks two
levels up to the repo copy, so pytest works without any env vars.
furtka-status.sh drops the /etc/furtka/version TODO — it now reads
/opt/furtka/VERSION directly, which Slice 1b will upgrade to
/opt/furtka/current/VERSION once the symlink layout lands.
_FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline;
it's tiny and not asset-shaped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Earlier config was enable_push=false + apply_to_admins=false, which I
expected to let the repo owner push directly. Empirically it blocked
owner pushes too — apply_to_admins governs approval-rule bypass, not
push-rule bypass. Switch to enable_push=true with enable_push_whitelist
and a single entry so the owner has explicit, auditable direct-push
access while casual commits still can't land without being whitelisted
or going through a PR.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1 of updates. User clicks Update on an installed app row →
the resource manager runs `docker compose pull`, compares the
running container's image ID to the just-pulled local image ID
per service, and only runs `docker compose up -d` if something
actually changed. Response is {updated: bool, services: [{service,
from, to, tag}]} so the UI can tell the user what happened.
Deliberately small: no pinning, no background checks, no "update
all" button, no version/changelog display. The update flow doesn't
mutate the compose file — it just acts on what's already there.
Reinstall still serves as rollback.
New dockerops helpers: compose_pull, compose_image_tags (parses
`docker compose config --format json`), local_image_id (via
`docker image inspect`), running_container_image_id (via compose
ps --quiet + docker inspect). Six new tests cover the endpoint:
not installed, no changes, changes applied, service not running,
docker pull error, and the HTTP route end-to-end.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slice 5 of the on-box UI uplevel. Adds a third page at /settings/
served by Caddy from /srv/furtka/www/settings/index.html. Three
groups of content:
- About this box (read-only): hostname, IP, Furtka version,
kernel, RAM, Docker, uptime — all consumed from status.json
via the same 30s refresh loop the landing uses.
- Appearance: theme follows prefers-color-scheme, language is
English for v1. Shown read-only.
- Coming next: linked roadmap chips (Reboot / Shut down / Change
hostname / Backup / User accounts / Remote access), each
jumping to the planned section on furtka.org. Implementing any
of these graduates it in-place.
Nav link to Settings also added to the landing page and /apps so
the three pages share one persistent navigation (Jakob's Law).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slice 4 of the on-box UI uplevel. The landing page is now the peak-end
first impression after install: welcome + hostname chip, a "Your apps"
tile grid consuming /api/apps (with the real icon and an app-specific
primary action — fileshare gets smb://<host>.local/files, everything
else falls back to Manage →), the existing system-status tiles kept
intact, and a subtle "Coming next" row of text-only links that jump to
the planned-features section on furtka.org. No dead tiles.
The status script now also writes ip_primary, kernel, ram_total and a
furtka_version read from /etc/furtka/version (TODO: pin that file at
install time; for now it reports "dev"). The settings page will
consume those in slice 5.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the generic stroke-only folder with a flat two-layer
folder (depth via a 28%-opacity back plate) plus two signal arcs
and a node dot radiating from the top-right corner. currentColor
throughout so the .app-icon tint applies cleanly in both themes.
Manifest version bumped to 0.1.1 so the resource manager sees the
change as a distinct release.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slice 2 of the on-box UI uplevel. The resource-manager API already
returned the icon filename in each manifest summary, but the /apps
page never rendered it — and there was no endpoint to fetch the
file either. This inlines the SVG content directly into the JSON
response (one round-trip, Doherty Threshold) and injects it into
each app card's new icon slot on the left.
_read_icon_svg defends against the obvious SVG-XSS vectors (script
tags, on* handlers, javascript: URLs) and rejects anything over
16 KB. The trust model stays what it was — bundled apps are built
into the ISO, the install API has no auth — but the filter keeps
accidents from becoming exploits if an icon gets swapped upstream.
/apps now shows a generic folder fallback for any app without a
parseable icon.svg; slice 3 ships the real fileshare artwork.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slice 1 of the on-box UI uplevel. Consolidates the two duplicated
stylesheets (landing's webinstaller/app.py and /apps's inline block
in furtka/api.py) into one sheet served by Caddy at /style.css.
Expands the token set (spacing, radii, shadows, focus ring, warn-fg,
accent-soft, card-hover), adds a prefers-color-scheme light theme,
and introduces shared primitives for later slices: .nav, .chip,
.card, .kv, .coming, .grid-apps, .app-tile, .app-icon.
Also adds a persistent top nav (Home / Apps) to both pages — Jakob's
Law, so users always have a way back — and collapses the /apps "Last
action" log behind a details disclosure so it stops dominating the
page. Format fallout on drives.py picked up by ruff.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codifies the branch protection applied to main on 2026-04-16: no
direct pushes, required checks = CI / {lint,test,validate-json}*,
zero approvals (2-person team), admin bypass left on for emergencies.
Script is idempotent (create-or-patch) and reads its token from
\$FORGEJO_TOKEN or the local git remote URL as a fallback, so a
clean re-run just reconciles the rule with branch-protection.json.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Leftover German string from prototyping — the rest of the apps UI is
English, so it stood out as a mixed-language bug during 2026-04-16
VM testing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the numeric "score N" pill with a Recommended badge on the
auto-selected drive plus size/type/health chips. The score itself
stays as the sort key, users just never see the raw number.
Why: Robert's 2026-04-14 wizard UX direction — less jargon, explain
Fachbegriffs, recommend defaults. A bare "score 35" gave users no
reason why one drive was picked.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reflects commit 61c7ee2 — manifest gains `settings` + `description_long`,
API gains `GET/POST /api/apps/<name>/settings`, install/reinstall accepts
a `settings` object. Drops the stale "in-UI .env editor" from the
out-of-scope list since that's what just shipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>