furtka/tests/test_updater.py

463 lines
17 KiB
Python
Raw Permalink Normal View History

feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI 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>
2026-04-16 13:30:45 +02:00
"""Tests for the Phase-2 self-update logic.
These tests exercise the pure logic in furtka/updater.py: version compare,
sha256 verify, tarball extract, symlink swap, state writes. The service-
restart + health-check paths are stubbed so tests don't talk to systemd or
hit the network.
FURTKA_ROOT + FURTKA_STATE_DIR + FURTKA_LOCK_PATH all override to tmp_path
so each test gets a clean filesystem.
"""
import hashlib
import io
import tarfile
from pathlib import Path
import pytest
@pytest.fixture
def updater(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_ROOT", str(tmp_path / "opt_furtka"))
monkeypatch.setenv("FURTKA_STATE_DIR", str(tmp_path / "var_lib_furtka"))
monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock"))
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
monkeypatch.setenv("FURTKA_CADDYFILE_PATH", str(tmp_path / "etc_caddy" / "Caddyfile"))
monkeypatch.setenv("FURTKA_SYSTEMD_DIR", str(tmp_path / "etc_systemd_system"))
fix(https): restore TLS handshake — name hostname + correct PKI path Closes #10. Two linked bugs in 26.4-alpha's Phase 1 HTTPS made the force-HTTPS toggle fatal: every SNI handshake on :443 died with SSL_ERROR_INTERNAL_ERROR_ALERT, so the toggle redirected users from working HTTP to broken HTTPS. Root cause 1: bare `:443 { tls internal }` gives Caddy no hostname to issue a leaf cert for, so /var/lib/caddy/certificates/ stayed empty and Caddy sent TLS `internal_error` on every handshake. Fix: the :443 block is now `__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ { tls internal }`, with the marker substituted by webinstaller/app.py at install time and by furtka.updater._refresh_caddyfile on self-update (reads /etc/hostname, falls back to "furtka"). `auto_https disable_redirects` keeps Caddy's built-in redirect out of the way of the /settings toggle. Root cause 2: furtka/https.py and the /rootCA.crt handler both referenced /var/lib/caddy/.local/share/caddy/pki/authorities/local/ — a path that doesn't exist. caddy.service sets XDG_DATA_HOME=/var/lib, so Caddy's storage is /var/lib/caddy/ directly. Fix: both paths corrected. Verified on the 192.168.178.110 smoke VM by swapping the Caddyfile in, reloading, handshaking, restoring: TLS 1.3 handshake succeeds, leaf cert issued under /var/lib/caddy/certificates/local/, /rootCA.crt returns 200. Tests: new cases assert the Caddyfile ships the hostname placeholder, the webinstaller substitutes it, _refresh_caddyfile re-substitutes from /etc/hostname on update, and the asset sets auto_https disable_redirects. Unit tests still stub the Caddy reload — the real handshake regression needs a smoke-VM integration test (follow-up, separate from this fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:38:16 +02:00
hostname_file = tmp_path / "etc_hostname"
hostname_file.write_text("testbox\n")
monkeypatch.setenv("FURTKA_HOSTNAME_FILE", str(hostname_file))
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
(tmp_path / "etc_systemd_system").mkdir()
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI 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>
2026-04-16 13:30:45 +02:00
# Reload the module so the path constants pick up the env vars.
import importlib
import furtka.updater as u
importlib.reload(u)
return u
def _make_tarball(path: Path, version: str):
"""Build a minimal valid Furtka release tarball at `path`."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for name, content in [
("VERSION", f"{version}\n"),
("furtka/__init__.py", ""),
("apps/fileshare/manifest.json", "{}"),
]:
data = content.encode()
info = tarfile.TarInfo(name=name)
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
path.write_bytes(buf.getvalue())
def test_version_tuple_orders_prereleases_before_stable(updater):
vt = updater._version_tuple
assert vt("26.0-alpha") < vt("26.0-beta")
assert vt("26.0-beta") < vt("26.0-rc1")
assert vt("26.0-rc1") < vt("26.0")
assert vt("26.0") < vt("26.1-alpha")
assert vt("26.1-alpha") < vt("27.0-alpha")
def test_verify_tarball_accepts_matching_hash(tmp_path, updater):
tar = tmp_path / "t.tar.gz"
tar.write_bytes(b"hello world")
sha = hashlib.sha256(b"hello world").hexdigest()
updater.verify_tarball(tar, sha) # no raise
def test_verify_tarball_rejects_mismatch(tmp_path, updater):
tar = tmp_path / "t.tar.gz"
tar.write_bytes(b"hello world")
with pytest.raises(updater.UpdateError, match="sha256 mismatch"):
updater.verify_tarball(tar, "0" * 64)
def test_parse_sha256_sidecar_strips_filename(updater):
line = "abc123 furtka-26.1-alpha.tar.gz\n"
assert updater._parse_sha256_sidecar(line) == "abc123"
def test_parse_sha256_sidecar_rejects_empty(updater):
with pytest.raises(updater.UpdateError):
updater._parse_sha256_sidecar("")
def test_extract_tarball_returns_version_and_refuses_unsafe_paths(tmp_path, updater):
tar = tmp_path / "t.tar.gz"
_make_tarball(tar, "26.2-alpha")
dest = tmp_path / "dest"
assert updater._extract_tarball(tar, dest) == "26.2-alpha"
assert (dest / "VERSION").read_text().strip() == "26.2-alpha"
# Build a malicious tarball with a traversal path; must refuse.
evil = tmp_path / "evil.tar.gz"
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
info = tarfile.TarInfo(name="../escape")
info.size = 0
tf.addfile(info, io.BytesIO(b""))
evil.write_bytes(buf.getvalue())
with pytest.raises(updater.UpdateError, match="refusing"):
updater._extract_tarball(evil, tmp_path / "dest2")
def test_write_and_read_state_round_trip(updater):
updater.write_state("downloading", latest="26.2-alpha")
s = updater.read_state()
assert s["stage"] == "downloading"
assert s["latest"] == "26.2-alpha"
assert "updated_at" in s
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
def _make_release_tarball(path: Path, version: str, caddyfile_body: str = "# caddy\n"):
"""Richer tarball with assets/Caddyfile + assets/systemd/ — enough for
apply_update's post-swap integration (caddy refresh, unit linking)."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for name, content in [
("VERSION", f"{version}\n"),
("furtka/__init__.py", ""),
("apps/fileshare/manifest.json", "{}"),
("assets/Caddyfile", caddyfile_body),
("assets/systemd/furtka-api.service", "[Service]\nExecStart=/bin/true\n"),
]:
data = content.encode()
info = tarfile.TarInfo(name=name)
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
path.write_bytes(buf.getvalue())
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI 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>
2026-04-16 13:30:45 +02:00
def test_apply_update_happy_path(tmp_path, updater, monkeypatch):
# Set up an existing "26.0-alpha" current symlink so apply_update has
# something to swap out.
versions = updater.versions_dir()
versions.mkdir(parents=True)
(versions / "26.0-alpha").mkdir()
(versions / "26.0-alpha" / "VERSION").write_text("26.0-alpha\n")
current = updater.current_symlink()
current.symlink_to(versions / "26.0-alpha")
tar = tmp_path / "t.tar.gz"
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
_make_release_tarball(tar, "26.1-alpha", caddyfile_body="# new caddy config\n")
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI 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>
2026-04-16 13:30:45 +02:00
# Stub the shell-out + health check — both succeed.
monkeypatch.setattr(updater, "_run", lambda cmd: None)
monkeypatch.setattr(updater, "_health_check", lambda url, deadline_s=30.0: True)
updater.apply_update(tar, "26.1-alpha")
assert current.resolve() == (versions / "26.1-alpha").resolve()
assert (versions / "26.1-alpha" / "VERSION").read_text().strip() == "26.1-alpha"
# Version dir is 755 so Caddy can traverse it — the staging dir came
# from mktemp-ish extractall which defaults to 700, and carries through
# the rename unless we explicitly chmod.
assert (versions / "26.1-alpha").stat().st_mode & 0o777 == 0o755
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
# P1-2: Caddyfile was copied into /etc/caddy/ from the new version.
assert updater._CADDYFILE_LIVE.read_text() == "# new caddy config\n"
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI 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>
2026-04-16 13:30:45 +02:00
state = updater.read_state()
assert state["stage"] == "done"
assert state["version"] == "26.1-alpha"
def test_apply_update_rolls_back_on_health_check_failure(tmp_path, updater, monkeypatch):
versions = updater.versions_dir()
versions.mkdir(parents=True)
(versions / "26.0-alpha").mkdir()
(versions / "26.0-alpha" / "VERSION").write_text("26.0-alpha\n")
current = updater.current_symlink()
current.symlink_to(versions / "26.0-alpha")
tar = tmp_path / "t.tar.gz"
_make_tarball(tar, "26.1-alpha")
# _run succeeds (restart "works"), but the API never comes back healthy.
monkeypatch.setattr(updater, "_run", lambda cmd: None)
monkeypatch.setattr(updater, "_health_check", lambda url, deadline_s=30.0: False)
# The rollback path calls subprocess.run directly — stub that too so we
# don't actually try to restart a real service.
import subprocess
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: None)
with pytest.raises(updater.UpdateError, match="rolled back"):
updater.apply_update(tar, "26.1-alpha")
# Symlink should point back at 26.0-alpha.
assert current.resolve() == (versions / "26.0-alpha").resolve()
state = updater.read_state()
assert state["stage"] == "rolled_back"
assert state["failed_version"] == "26.1-alpha"
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
def test_refresh_caddyfile_copies_when_different(updater, tmp_path):
# Fresh /etc/caddy/ — source wins.
src = tmp_path / "src"
src.write_text("# new\n")
assert updater._refresh_caddyfile(src) is True
assert updater._CADDYFILE_LIVE.read_text() == "# new\n"
# Same content — no-op.
assert updater._refresh_caddyfile(src) is False
def test_refresh_caddyfile_noops_if_source_missing(updater, tmp_path):
assert updater._refresh_caddyfile(tmp_path / "does-not-exist") is False
fix(https): restore TLS handshake — name hostname + correct PKI path Closes #10. Two linked bugs in 26.4-alpha's Phase 1 HTTPS made the force-HTTPS toggle fatal: every SNI handshake on :443 died with SSL_ERROR_INTERNAL_ERROR_ALERT, so the toggle redirected users from working HTTP to broken HTTPS. Root cause 1: bare `:443 { tls internal }` gives Caddy no hostname to issue a leaf cert for, so /var/lib/caddy/certificates/ stayed empty and Caddy sent TLS `internal_error` on every handshake. Fix: the :443 block is now `__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ { tls internal }`, with the marker substituted by webinstaller/app.py at install time and by furtka.updater._refresh_caddyfile on self-update (reads /etc/hostname, falls back to "furtka"). `auto_https disable_redirects` keeps Caddy's built-in redirect out of the way of the /settings toggle. Root cause 2: furtka/https.py and the /rootCA.crt handler both referenced /var/lib/caddy/.local/share/caddy/pki/authorities/local/ — a path that doesn't exist. caddy.service sets XDG_DATA_HOME=/var/lib, so Caddy's storage is /var/lib/caddy/ directly. Fix: both paths corrected. Verified on the 192.168.178.110 smoke VM by swapping the Caddyfile in, reloading, handshaking, restoring: TLS 1.3 handshake succeeds, leaf cert issued under /var/lib/caddy/certificates/local/, /rootCA.crt returns 200. Tests: new cases assert the Caddyfile ships the hostname placeholder, the webinstaller substitutes it, _refresh_caddyfile re-substitutes from /etc/hostname on update, and the asset sets auto_https disable_redirects. Unit tests still stub the Caddy reload — the real handshake regression needs a smoke-VM integration test (follow-up, separate from this fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:38:16 +02:00
def test_refresh_caddyfile_substitutes_hostname_placeholder(updater, tmp_path):
# Self-update rewrites the shipped Caddyfile against the box's real
# hostname, same substitution the installer does on first boot. Without
# this the named-hostname :443 block ships with a literal
# `__FURTKA_HOSTNAME__` and Caddy refuses to load the config.
src = tmp_path / "src"
src.write_text("__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ {\n\ttls internal\n}\n")
fix(https): restore TLS handshake — name hostname + correct PKI path Closes #10. Two linked bugs in 26.4-alpha's Phase 1 HTTPS made the force-HTTPS toggle fatal: every SNI handshake on :443 died with SSL_ERROR_INTERNAL_ERROR_ALERT, so the toggle redirected users from working HTTP to broken HTTPS. Root cause 1: bare `:443 { tls internal }` gives Caddy no hostname to issue a leaf cert for, so /var/lib/caddy/certificates/ stayed empty and Caddy sent TLS `internal_error` on every handshake. Fix: the :443 block is now `__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ { tls internal }`, with the marker substituted by webinstaller/app.py at install time and by furtka.updater._refresh_caddyfile on self-update (reads /etc/hostname, falls back to "furtka"). `auto_https disable_redirects` keeps Caddy's built-in redirect out of the way of the /settings toggle. Root cause 2: furtka/https.py and the /rootCA.crt handler both referenced /var/lib/caddy/.local/share/caddy/pki/authorities/local/ — a path that doesn't exist. caddy.service sets XDG_DATA_HOME=/var/lib, so Caddy's storage is /var/lib/caddy/ directly. Fix: both paths corrected. Verified on the 192.168.178.110 smoke VM by swapping the Caddyfile in, reloading, handshaking, restoring: TLS 1.3 handshake succeeds, leaf cert issued under /var/lib/caddy/certificates/local/, /rootCA.crt returns 200. Tests: new cases assert the Caddyfile ships the hostname placeholder, the webinstaller substitutes it, _refresh_caddyfile re-substitutes from /etc/hostname on update, and the asset sets auto_https disable_redirects. Unit tests still stub the Caddy reload — the real handshake regression needs a smoke-VM integration test (follow-up, separate from this fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:38:16 +02:00
assert updater._refresh_caddyfile(src) is True
live = updater._CADDYFILE_LIVE.read_text()
assert "testbox.local, testbox {" in live
assert "__FURTKA_HOSTNAME__" not in live
# Second call with the same source is a no-op — rendered content matches.
assert updater._refresh_caddyfile(src) is False
fix: unbreak upgrade path + install-lock race Three interlocking issues that made 26.11/26.12 effectively un-upgradable from pre-auth versions without manual pacman + symlink surgery. Caught while SSH-testing the .196 VM which landed on a rollback loop after every Update-now click. 1. auth.py imported werkzeug.security, but the target system runs core as bare system Python — neither flask nor werkzeug are pip-installed. Fresh 26.11+ boxes died on import. Replaced with a 50-line stdlib `furtka/passwd.py` using hashlib.pbkdf2_hmac for new hashes and parsing werkzeug's `scrypt:N:r:p$salt$hex` format for backward-read so existing users.json survives. 2. updater._health_check pinged /api/apps expecting 200. Post- auth, /api/apps returns 401 for unauth requests → HTTPError caught as URLError → retry loop → 30s timeout → rollback. Now any 2xx-4xx counts as "server alive"; only 5xx / connection errors fail. Server responding at all is proof it came back up. 3. _do_install released the fcntl lock between sync pre-validation and the systemd-run dispatch. A second POST could slip in, pass the lock check, return 202, and leave its install-bg child to die silently on the in-child lock. Now the API also reads install-state.json and refuses 409 on non-terminal stages — the state file is the reliable signal, the fcntl lock is defence in depth. Test coverage: - tests/test_passwd.py (new, 6 cases): roundtrip, salt uniqueness, format shape, werkzeug scrypt backward-compat against a real hash captured from the .196 box, malformed + non-string rejection. - tests/test_updater.py: +3 cases for _health_check — 4xx=healthy, 5xx=unhealthy, URLError retry loop. - tests/test_api.py: +2 cases for install 409 on non-terminal state + 202 after terminal. All 267 tests green, ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:03:28 +02:00
def test_health_check_treats_4xx_as_healthy(updater, monkeypatch):
"""26.11+ auth makes /api/apps return 401 on unauth requests. If the
health check treated that as "down", every pre-auth auth upgrade
auto-rolls back. Server responding at all is enough signal for the
health check."""
import urllib.error
calls = {"n": 0}
class _FakeResp:
def __init__(self, code):
self.status = code
def __enter__(self):
return self
def __exit__(self, *a):
return False
def raising_401(url, timeout):
calls["n"] += 1
raise urllib.error.HTTPError(url, 401, "Unauthorized", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising_401)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=2.0) is True
# One call was enough — early exit on 4xx, no retry loop.
assert calls["n"] == 1
def test_health_check_rejects_5xx(updater, monkeypatch):
"""500s mean the server is up but broken — that's NOT healthy.
Distinguishes auth refusals (4xx = healthy) from real runtime
errors (5xx = unhealthy, roll back)."""
import urllib.error
def raising_500(url, timeout):
raise urllib.error.HTTPError(url, 500, "Internal Server Error", {}, None)
monkeypatch.setattr("urllib.request.urlopen", raising_500)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=1.5) is False
def test_health_check_retries_on_connection_refused(updater, monkeypatch):
"""While furtka-api is still starting, urlopen raises URLError.
The loop must keep polling until the server comes up or deadline."""
import urllib.error
calls = {"n": 0}
def flaky(url, timeout):
calls["n"] += 1
if calls["n"] < 3:
raise urllib.error.URLError("connection refused")
class _Resp:
status = 200
def __enter__(self):
return self
def __exit__(self, *a):
return False
return _Resp()
monkeypatch.setattr("urllib.request.urlopen", flaky)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=10.0) is True
assert calls["n"] == 3
fix(https): restore TLS handshake — name hostname + correct PKI path Closes #10. Two linked bugs in 26.4-alpha's Phase 1 HTTPS made the force-HTTPS toggle fatal: every SNI handshake on :443 died with SSL_ERROR_INTERNAL_ERROR_ALERT, so the toggle redirected users from working HTTP to broken HTTPS. Root cause 1: bare `:443 { tls internal }` gives Caddy no hostname to issue a leaf cert for, so /var/lib/caddy/certificates/ stayed empty and Caddy sent TLS `internal_error` on every handshake. Fix: the :443 block is now `__FURTKA_HOSTNAME__.local, __FURTKA_HOSTNAME__ { tls internal }`, with the marker substituted by webinstaller/app.py at install time and by furtka.updater._refresh_caddyfile on self-update (reads /etc/hostname, falls back to "furtka"). `auto_https disable_redirects` keeps Caddy's built-in redirect out of the way of the /settings toggle. Root cause 2: furtka/https.py and the /rootCA.crt handler both referenced /var/lib/caddy/.local/share/caddy/pki/authorities/local/ — a path that doesn't exist. caddy.service sets XDG_DATA_HOME=/var/lib, so Caddy's storage is /var/lib/caddy/ directly. Fix: both paths corrected. Verified on the 192.168.178.110 smoke VM by swapping the Caddyfile in, reloading, handshaking, restoring: TLS 1.3 handshake succeeds, leaf cert issued under /var/lib/caddy/certificates/local/, /rootCA.crt returns 200. Tests: new cases assert the Caddyfile ships the hostname placeholder, the webinstaller substitutes it, _refresh_caddyfile re-substitutes from /etc/hostname on update, and the asset sets auto_https disable_redirects. Unit tests still stub the Caddy reload — the real handshake regression needs a smoke-VM integration test (follow-up, separate from this fix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:38:16 +02:00
def test_current_hostname_falls_back_when_file_missing(updater, monkeypatch, tmp_path):
monkeypatch.setenv("FURTKA_HOSTNAME_FILE", str(tmp_path / "missing"))
import importlib
importlib.reload(updater)
assert updater._current_hostname() == "furtka"
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
unit_dir = tmp_path / "assets_systemd"
unit_dir.mkdir()
(unit_dir / "furtka-foo.service").write_text("[Service]\nExecStart=/bin/true\n")
(unit_dir / "furtka-bar.timer").write_text("[Timer]\nOnBootSec=1s\n")
(unit_dir / "ignored.txt").write_text("not a unit")
# Pretend furtka-foo is already linked — it must be skipped.
(updater._SYSTEMD_DIR / "furtka-foo.service").symlink_to("/dev/null")
seen = []
monkeypatch.setattr(updater, "_run", lambda cmd: seen.append(cmd))
linked = updater._link_new_units(unit_dir)
assert linked == ["furtka-bar.timer"]
feat(catalog): on-box apps catalog synced independently of core version New `furtka catalog sync` pulls the latest daniel/furtka-apps release, verifies its sha256, extracts under /var/lib/furtka/catalog/, and atomically swaps into place — so apps can ship without cutting a new Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a manual "Sync apps catalog" button that kicks the same code path via a detached systemd-run unit. Layout of the new on-box tree: /var/lib/furtka/catalog/ synced catalog (survives self-updates) ├── VERSION └── apps/<name>/ ... /var/lib/furtka/catalog-state.json sync stage + last version, UI-polled /run/furtka/catalog.lock flock so timer + manual click can't race Resolver precedence (furtka/sources.py): catalog wins over the bundled seed (/opt/furtka/current/apps/, carried by the core release for offline first-boot). Installed apps under /var/lib/furtka/apps/ are never auto- swapped — user clicks Reinstall to move an existing install onto a newer catalog version; settings merge-preserved via the existing installer.install_from path. New files: - furtka/_release_common.py — shared Forgejo/tarball primitives lifted from furtka/updater.py. Both modules now import from here; updater's behaviour and public API unchanged. - furtka/catalog.py — check_catalog(), sync_catalog() with staging + manifest validation + atomic rename. Refuses bad sha256 / broken manifests and leaves the live catalog intact on any failure path. - furtka/sources.py — resolve_app_name() / list_available() abstraction used by installer.resolve_source and api._list_available. - assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service + daily timer. Timer auto-enables on self-update via a one-line addition to _link_new_units (fresh installs get enabled via the webinstaller's _FURTKA_UNITS list). API + UI: - /api/bundled renamed internally to _list_available; endpoint stays as a backcompat alias; /api/apps/available is the new canonical name. Each list entry carries a `source` field ("catalog" | "bundled"). - POST /api/catalog/sync/check + /apply + GET /api/catalog/status. - /apps page grows a catalog-status row + Sync button; poll loop mirrors the Furtka self-update flow. CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both support --json). Old `furtka app install` / `reconcile` / `update` / `rollback` surfaces are unchanged. Test gate: 194/170 baseline + 24 new tests covering catalog sync (happy path, sha256 mismatch, invalid manifest, lock contention, preserves-on-failure) + resolver precedence + api renames. ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:02 +02:00
# Two calls for the newly-linked timer: systemctl link + systemctl enable.
# The already-linked service is untouched. Timers need the follow-up
# `enable` so self-updates that introduce new timers don't leave them
# dormant — fresh installs get their enable via the webinstaller.
assert len(seen) == 2
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
assert seen[0][:2] == ["systemctl", "link"]
assert seen[0][2].endswith("furtka-bar.timer")
feat(catalog): on-box apps catalog synced independently of core version New `furtka catalog sync` pulls the latest daniel/furtka-apps release, verifies its sha256, extracts under /var/lib/furtka/catalog/, and atomically swaps into place — so apps can ship without cutting a new Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a manual "Sync apps catalog" button that kicks the same code path via a detached systemd-run unit. Layout of the new on-box tree: /var/lib/furtka/catalog/ synced catalog (survives self-updates) ├── VERSION └── apps/<name>/ ... /var/lib/furtka/catalog-state.json sync stage + last version, UI-polled /run/furtka/catalog.lock flock so timer + manual click can't race Resolver precedence (furtka/sources.py): catalog wins over the bundled seed (/opt/furtka/current/apps/, carried by the core release for offline first-boot). Installed apps under /var/lib/furtka/apps/ are never auto- swapped — user clicks Reinstall to move an existing install onto a newer catalog version; settings merge-preserved via the existing installer.install_from path. New files: - furtka/_release_common.py — shared Forgejo/tarball primitives lifted from furtka/updater.py. Both modules now import from here; updater's behaviour and public API unchanged. - furtka/catalog.py — check_catalog(), sync_catalog() with staging + manifest validation + atomic rename. Refuses bad sha256 / broken manifests and leaves the live catalog intact on any failure path. - furtka/sources.py — resolve_app_name() / list_available() abstraction used by installer.resolve_source and api._list_available. - assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service + daily timer. Timer auto-enables on self-update via a one-line addition to _link_new_units (fresh installs get enabled via the webinstaller's _FURTKA_UNITS list). API + UI: - /api/bundled renamed internally to _list_available; endpoint stays as a backcompat alias; /api/apps/available is the new canonical name. Each list entry carries a `source` field ("catalog" | "bundled"). - POST /api/catalog/sync/check + /apply + GET /api/catalog/status. - /apps page grows a catalog-status row + Sync button; poll loop mirrors the Furtka self-update flow. CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both support --json). Old `furtka app install` / `reconcile` / `update` / `rollback` surfaces are unchanged. Test gate: 194/170 baseline + 24 new tests covering catalog sync (happy path, sha256 mismatch, invalid manifest, lock contention, preserves-on-failure) + resolver precedence + api renames. ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:02 +02:00
assert seen[1] == ["systemctl", "enable", "furtka-bar.timer"]
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch):
# Confirm we pass filter='data' to extractall on Python 3.12+; fall back
feat(catalog): on-box apps catalog synced independently of core version New `furtka catalog sync` pulls the latest daniel/furtka-apps release, verifies its sha256, extracts under /var/lib/furtka/catalog/, and atomically swaps into place — so apps can ship without cutting a new Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a manual "Sync apps catalog" button that kicks the same code path via a detached systemd-run unit. Layout of the new on-box tree: /var/lib/furtka/catalog/ synced catalog (survives self-updates) ├── VERSION └── apps/<name>/ ... /var/lib/furtka/catalog-state.json sync stage + last version, UI-polled /run/furtka/catalog.lock flock so timer + manual click can't race Resolver precedence (furtka/sources.py): catalog wins over the bundled seed (/opt/furtka/current/apps/, carried by the core release for offline first-boot). Installed apps under /var/lib/furtka/apps/ are never auto- swapped — user clicks Reinstall to move an existing install onto a newer catalog version; settings merge-preserved via the existing installer.install_from path. New files: - furtka/_release_common.py — shared Forgejo/tarball primitives lifted from furtka/updater.py. Both modules now import from here; updater's behaviour and public API unchanged. - furtka/catalog.py — check_catalog(), sync_catalog() with staging + manifest validation + atomic rename. Refuses bad sha256 / broken manifests and leaves the live catalog intact on any failure path. - furtka/sources.py — resolve_app_name() / list_available() abstraction used by installer.resolve_source and api._list_available. - assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service + daily timer. Timer auto-enables on self-update via a one-line addition to _link_new_units (fresh installs get enabled via the webinstaller's _FURTKA_UNITS list). API + UI: - /api/bundled renamed internally to _list_available; endpoint stays as a backcompat alias; /api/apps/available is the new canonical name. Each list entry carries a `source` field ("catalog" | "bundled"). - POST /api/catalog/sync/check + /apply + GET /api/catalog/status. - /apps page grows a catalog-status row + Sync button; poll loop mirrors the Furtka self-update flow. CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both support --json). Old `furtka app install` / `reconcile` / `update` / `rollback` surfaces are unchanged. Test gate: 194/170 baseline + 24 new tests covering catalog sync (happy path, sha256 mismatch, invalid manifest, lock contention, preserves-on-failure) + resolver precedence + api renames. ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:02 +02:00
# cleanly on older runtimes. Capture the kwarg via a stub. tarfile lives
# in furtka._release_common after the extraction refactor, so we patch
# that module — updater._extract_tarball delegates there.
from furtka import _release_common as _rc
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
calls = []
feat(catalog): on-box apps catalog synced independently of core version New `furtka catalog sync` pulls the latest daniel/furtka-apps release, verifies its sha256, extracts under /var/lib/furtka/catalog/, and atomically swaps into place — so apps can ship without cutting a new Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a manual "Sync apps catalog" button that kicks the same code path via a detached systemd-run unit. Layout of the new on-box tree: /var/lib/furtka/catalog/ synced catalog (survives self-updates) ├── VERSION └── apps/<name>/ ... /var/lib/furtka/catalog-state.json sync stage + last version, UI-polled /run/furtka/catalog.lock flock so timer + manual click can't race Resolver precedence (furtka/sources.py): catalog wins over the bundled seed (/opt/furtka/current/apps/, carried by the core release for offline first-boot). Installed apps under /var/lib/furtka/apps/ are never auto- swapped — user clicks Reinstall to move an existing install onto a newer catalog version; settings merge-preserved via the existing installer.install_from path. New files: - furtka/_release_common.py — shared Forgejo/tarball primitives lifted from furtka/updater.py. Both modules now import from here; updater's behaviour and public API unchanged. - furtka/catalog.py — check_catalog(), sync_catalog() with staging + manifest validation + atomic rename. Refuses bad sha256 / broken manifests and leaves the live catalog intact on any failure path. - furtka/sources.py — resolve_app_name() / list_available() abstraction used by installer.resolve_source and api._list_available. - assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service + daily timer. Timer auto-enables on self-update via a one-line addition to _link_new_units (fresh installs get enabled via the webinstaller's _FURTKA_UNITS list). API + UI: - /api/bundled renamed internally to _list_available; endpoint stays as a backcompat alias; /api/apps/available is the new canonical name. Each list entry carries a `source` field ("catalog" | "bundled"). - POST /api/catalog/sync/check + /apply + GET /api/catalog/status. - /apps page grows a catalog-status row + Sync button; poll loop mirrors the Furtka self-update flow. CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both support --json). Old `furtka app install` / `reconcile` / `update` / `rollback` surfaces are unchanged. Test gate: 194/170 baseline + 24 new tests covering catalog sync (happy path, sha256 mismatch, invalid manifest, lock contention, preserves-on-failure) + resolver precedence + api renames. ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:02 +02:00
real_open = _rc.tarfile.open # capture before monkeypatching
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
class _Recorder:
def __init__(self, tarball):
self._tb = real_open(tarball, "r:gz")
def __enter__(self):
return self
def __exit__(self, *a):
self._tb.close()
def getmembers(self):
return self._tb.getmembers()
def extractall(self, *args, **kwargs):
calls.append(("extractall", args, kwargs))
# Force the TypeError branch when filter is passed, then re-run
# without — matches the older-Python fallback.
if "filter" in kwargs:
raise TypeError("old python")
return self._tb.extractall(*args)
tar = tmp_path / "t.tar.gz"
_make_release_tarball(tar, "26.9-alpha")
feat(catalog): on-box apps catalog synced independently of core version New `furtka catalog sync` pulls the latest daniel/furtka-apps release, verifies its sha256, extracts under /var/lib/furtka/catalog/, and atomically swaps into place — so apps can ship without cutting a new Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a manual "Sync apps catalog" button that kicks the same code path via a detached systemd-run unit. Layout of the new on-box tree: /var/lib/furtka/catalog/ synced catalog (survives self-updates) ├── VERSION └── apps/<name>/ ... /var/lib/furtka/catalog-state.json sync stage + last version, UI-polled /run/furtka/catalog.lock flock so timer + manual click can't race Resolver precedence (furtka/sources.py): catalog wins over the bundled seed (/opt/furtka/current/apps/, carried by the core release for offline first-boot). Installed apps under /var/lib/furtka/apps/ are never auto- swapped — user clicks Reinstall to move an existing install onto a newer catalog version; settings merge-preserved via the existing installer.install_from path. New files: - furtka/_release_common.py — shared Forgejo/tarball primitives lifted from furtka/updater.py. Both modules now import from here; updater's behaviour and public API unchanged. - furtka/catalog.py — check_catalog(), sync_catalog() with staging + manifest validation + atomic rename. Refuses bad sha256 / broken manifests and leaves the live catalog intact on any failure path. - furtka/sources.py — resolve_app_name() / list_available() abstraction used by installer.resolve_source and api._list_available. - assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service + daily timer. Timer auto-enables on self-update via a one-line addition to _link_new_units (fresh installs get enabled via the webinstaller's _FURTKA_UNITS list). API + UI: - /api/bundled renamed internally to _list_available; endpoint stays as a backcompat alias; /api/apps/available is the new canonical name. Each list entry carries a `source` field ("catalog" | "bundled"). - POST /api/catalog/sync/check + /apply + GET /api/catalog/status. - /apps page grows a catalog-status row + Sync button; poll loop mirrors the Furtka self-update flow. CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both support --json). Old `furtka app install` / `reconcile` / `update` / `rollback` surfaces are unchanged. Test gate: 194/170 baseline + 24 new tests covering catalog sync (happy path, sha256 mismatch, invalid manifest, lock contention, preserves-on-failure) + resolver precedence + api renames. ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:02 +02:00
monkeypatch.setattr(_rc.tarfile, "open", lambda *a, **kw: _Recorder(tar))
fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking 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>
2026-04-16 14:10:07 +02:00
dest = tmp_path / "dest"
updater._extract_tarball(tar, dest)
# First call had filter=, second (fallback) didn't.
assert len(calls) == 2
assert calls[0][2] == {"filter": "data"}
assert calls[1][2] == {}
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI 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>
2026-04-16 13:30:45 +02:00
def test_apply_update_rejects_version_mismatch(tmp_path, updater, monkeypatch):
versions = updater.versions_dir()
versions.mkdir(parents=True)
tar = tmp_path / "t.tar.gz"
_make_tarball(tar, "26.1-alpha")
with pytest.raises(updater.UpdateError, match="doesn't match expected"):
updater.apply_update(tar, "26.2-alpha")
def test_acquire_lock_prevents_concurrent_runs(tmp_path, updater):
first = updater.acquire_lock()
try:
with pytest.raises(updater.UpdateError, match="already in progress"):
updater.acquire_lock()
finally:
first.close()
def test_read_current_version_falls_back_to_dev(updater):
# No symlink, no VERSION — should be "dev" not raise.
assert updater.read_current_version() == "dev"
def test_rollback_flips_to_previous_slot(tmp_path, updater, monkeypatch):
versions = updater.versions_dir()
versions.mkdir(parents=True)
for v in ("26.0-alpha", "26.1-alpha"):
(versions / v).mkdir()
(versions / v / "VERSION").write_text(f"{v}\n")
current = updater.current_symlink()
current.symlink_to(versions / "26.1-alpha")
import subprocess
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: None)
restored = updater.rollback()
assert restored == "26.0-alpha"
assert current.resolve() == (versions / "26.0-alpha").resolve()
def test_check_update_queries_forgejo_and_compares(updater, monkeypatch):
# Stub the API and the current-version read. Forgejo's /releases list
# returns most-recent first, including pre-releases — we take [0].
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI 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>
2026-04-16 13:30:45 +02:00
monkeypatch.setattr(updater, "read_current_version", lambda: "26.0-alpha")
monkeypatch.setattr(
updater,
"_forgejo_api",
lambda path: [
{
"tag_name": "26.1-alpha",
"assets": [
{
"name": "furtka-26.1-alpha.tar.gz",
"browser_download_url": "https://x/t.tar.gz",
},
{
"name": "furtka-26.1-alpha.tar.gz.sha256",
"browser_download_url": "https://x/t.tar.gz.sha256",
},
],
}
],
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI 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>
2026-04-16 13:30:45 +02:00
)
check = updater.check_update()
assert check.current == "26.0-alpha"
assert check.latest == "26.1-alpha"
assert check.update_available is True
assert check.tarball_url == "https://x/t.tar.gz"
assert check.sha256_url == "https://x/t.tar.gz.sha256"
def test_check_update_reports_up_to_date_when_same_version(updater, monkeypatch):
monkeypatch.setattr(updater, "read_current_version", lambda: "26.1-alpha")
monkeypatch.setattr(
updater,
"_forgejo_api",
lambda path: [{"tag_name": "26.1-alpha", "assets": []}],
feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI 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>
2026-04-16 13:30:45 +02:00
)
check = updater.check_update()
assert check.update_available is False
def test_check_update_raises_when_no_releases_published(updater, monkeypatch):
# Newly-created repo with zero releases: don't crash, surface a clean
# error the UI can show instead of "HTTP 404 Not Found".
monkeypatch.setattr(updater, "read_current_version", lambda: "26.0-alpha")
monkeypatch.setattr(updater, "_forgejo_api", lambda path: [])
with pytest.raises(updater.UpdateError, match="no releases"):
updater.check_update()