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"))
|
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))
|
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
|
|
|
|
|
|
|
|
|
|
|
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"
|
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"
|
2026-04-16 15:46:56 +02:00
|
|
|
# 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
|
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"
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
# Only one systemctl link call — for the new timer, not the existing service.
|
|
|
|
|
assert len(seen) == 1
|
|
|
|
|
assert seen[0][:2] == ["systemctl", "link"]
|
|
|
|
|
assert seen[0][2].endswith("furtka-bar.timer")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
# cleanly on older runtimes. Capture the kwarg via a stub.
|
|
|
|
|
calls = []
|
|
|
|
|
real_open = updater.tarfile.open # capture before monkeypatching
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
monkeypatch.setattr(updater.tarfile, "open", lambda *a, **kw: _Recorder(tar))
|
|
|
|
|
|
|
|
|
|
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):
|
2026-04-16 16:29:11 +02:00
|
|
|
# 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",
|
2026-04-16 16:29:11 +02:00
|
|
|
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",
|
2026-04-16 16:29:11 +02:00
|
|
|
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
|
2026-04-16 16:29:11 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|