From f0acc4427e9d700f6cd3f62a3dc98a4eea7843a9 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Thu, 16 Apr 2026 13:30:45 +0200 Subject: [PATCH] feat(furtka): release CI + \`furtka update\` / \`furtka rollback\` CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — packages the furtka/ package + bundled apps/ + a root-level VERSION file as dist/furtka-.tar.gz, plus a .sha256 sidecar and a release.json metadata blob. - scripts/publish-release.sh — 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) --- .forgejo/workflows/release.yml | 33 +++ RELEASING.md | 13 +- furtka/assets/VERSION | 1 - furtka/cli.py | 72 ++++++ furtka/updater.py | 371 ++++++++++++++++++++++++++++++ iso/build.sh | 8 +- scripts/build-release-tarball.sh | 49 ++++ scripts/publish-release.sh | 95 ++++++++ tests/test_updater.py | 244 ++++++++++++++++++++ tests/test_webinstaller_assets.py | 8 - 10 files changed, 878 insertions(+), 16 deletions(-) create mode 100644 .forgejo/workflows/release.yml delete mode 100644 furtka/assets/VERSION create mode 100644 furtka/updater.py create mode 100755 scripts/build-release-tarball.sh create mode 100755 scripts/publish-release.sh create mode 100644 tests/test_updater.py diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..9913639 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,33 @@ +name: Release + +# Tag-triggered: when `git push origin ` lands, this builds the +# release tarball and publishes it + the sha256 + release.json to the +# Forgejo releases page for that tag. Boxes then POST /api/furtka/update +# to pull from here. +# +# Version tags only (pattern matches CalVer like 26.0-alpha, 26.1, 27.0-beta). +# Documentation / random tags are ignored by the [0-9]* prefix. +on: + push: + tags: ['[0-9]*'] + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # changelog section extraction needs history + + - name: Install jq + run: | + apt-get update -qq + apt-get install -y jq + + - name: Build release tarball + run: ./scripts/build-release-tarball.sh "${GITHUB_REF_NAME}" + + - name: Publish to Forgejo releases + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }} + run: ./scripts/publish-release.sh "${GITHUB_REF_NAME}" diff --git a/RELEASING.md b/RELEASING.md index cc1b34f..de8288d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -45,13 +45,14 @@ Tag per meaningful milestone, not on a calendar. A milestone is: ISO boots, a wi git push origin 26.1-alpha ``` -5. **Create a Forgejo Release** at `https://forgejo.sourcegate.online/daniel/furtka/releases/new`: - - Tag: `26.1-alpha` (already exists) - - Title: `26.1-alpha` - - Body: paste the changelog section for this version - - Tick **Pre-release** for anything still `-alpha` or `-beta` +5. **The release workflow does the rest.** `.forgejo/workflows/release.yml` fires on the tag push: `scripts/build-release-tarball.sh` builds the self-update payload (tarball + sha256 + release.json under `dist/`), `scripts/publish-release.sh` uploads all three assets to the Forgejo release page. Pre-release is flagged automatically based on the suffix (`-alpha`/`-beta`/`-rc`). -6. **Verify CI passed on the tag.** The Forgejo Actions run against the tagged commit should be green before you announce the release anywhere. + The release workflow needs one secret set at repo **Settings → Secrets → Actions**: + - `FORGEJO_RELEASE_TOKEN` — a PAT with `write:repository` scope. + +6. **Verify CI passed on the tag.** The Forgejo Actions run against the tagged commit should be green before you announce the release anywhere — both the CI workflow (lint/test) and the Release workflow (tarball published). + +7. **(Optional) Dogfood the update path.** On a VM running the previous version, `sudo furtka update --check` should now see the new tag, and `sudo furtka update` applies it without a reinstall. ## First-time: find the current version diff --git a/furtka/assets/VERSION b/furtka/assets/VERSION deleted file mode 100644 index 422a5b4..0000000 --- a/furtka/assets/VERSION +++ /dev/null @@ -1 +0,0 @@ -26.0-alpha diff --git a/furtka/cli.py b/furtka/cli.py index 66625af..91dd41c 100644 --- a/furtka/cli.py +++ b/furtka/cli.py @@ -99,6 +99,56 @@ def _cmd_reconcile(args: argparse.Namespace) -> int: return 1 if reconciler.has_errors(actions) else 0 +def _cmd_update(args: argparse.Namespace) -> int: + from furtka import updater + + if args.check: + try: + check = updater.check_update() + except updater.UpdateError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + if args.json: + print( + json.dumps( + { + "current": check.current, + "latest": check.latest, + "update_available": check.update_available, + }, + indent=2, + ) + ) + elif check.update_available: + print(f"Update available: {check.current} → {check.latest}") + else: + print(f"Already up to date ({check.current})") + return 0 + + try: + check = updater.run_update() + except updater.UpdateError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + if not check.update_available: + print(f"Already up to date ({check.current})") + else: + print(f"Updated {check.current} → {check.latest}") + return 0 + + +def _cmd_rollback(args: argparse.Namespace) -> int: + from furtka import updater + + try: + restored = updater.rollback() + except updater.UpdateError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + print(f"Rolled back to {restored}") + return 0 + + def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="furtka", description="Furtka resource manager") sub = p.add_subparsers(dest="command", required=True) @@ -140,6 +190,28 @@ def build_parser() -> argparse.ArgumentParser: serve.add_argument("--port", type=int, default=7000, help="Bind port (default 7000)") serve.set_defaults(func=_cmd_serve) + update = sub.add_parser( + "update", + help="Check for or apply a Furtka release (Phase 2 self-update)", + ) + update.add_argument( + "--check", + action="store_true", + help="Only check whether an update is available; don't apply", + ) + update.add_argument( + "--json", + action="store_true", + help="Emit machine-readable JSON (only honoured with --check)", + ) + update.set_defaults(func=_cmd_update) + + rollback = sub.add_parser( + "rollback", + help="Flip /opt/furtka/current back to the previous version slot", + ) + rollback.set_defaults(func=_cmd_rollback) + return p diff --git a/furtka/updater.py b/furtka/updater.py new file mode 100644 index 0000000..b2322ad --- /dev/null +++ b/furtka/updater.py @@ -0,0 +1,371 @@ +"""Furtka self-update logic. + +The runtime layout (see also ``webinstaller/app.py`` slice 1b): + + /opt/furtka/ + ├── versions/ + │ ├── 26.0-alpha/ first install extracted here + │ └── 26.1-alpha/ next version, after one update + └── current -> versions/26.1-alpha + +This module handles the transition between versions. Flow: + +1. ``check_update()`` queries the Forgejo releases API for the latest tag. +2. ``prepare_update()`` downloads the tarball + sha256 sidecar, verifies it, + extracts into ``/opt/furtka/versions//_staging`` and moves it to + ``versions//`` on successful extract. +3. ``apply_update()`` flips ``/opt/furtka/current``, reloads caddy, and + restarts furtka-reconcile + furtka-api. Then health-checks the API. On + failure it flips the symlink back. + +The full pipeline is wrapped in ``run_update()`` for the CLI, which also +writes stage-by-stage progress to ``/var/lib/furtka/update-state.json`` so +the web UI can poll progress without touching the (restarting) API. + +Paths can be overridden via the ``FURTKA_ROOT`` env var so tests can point +the updater at a tmpdir. +""" + +from __future__ import annotations + +import fcntl +import hashlib +import json +import os +import shutil +import subprocess +import tarfile +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +FORGEJO_HOST = os.environ.get("FURTKA_FORGEJO_HOST", "forgejo.sourcegate.online") +FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka") +_FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka")) +_STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/var/lib/furtka")) + + +class UpdateError(RuntimeError): + """Any failure in the update flow that should surface to the caller.""" + + +def furtka_root() -> Path: + return _FURTKA_ROOT + + +def versions_dir() -> Path: + return furtka_root() / "versions" + + +def current_symlink() -> Path: + return furtka_root() / "current" + + +def state_path() -> Path: + return _STATE_DIR / "update-state.json" + + +def lock_path() -> Path: + return Path(os.environ.get("FURTKA_LOCK_PATH", "/run/furtka/update.lock")) + + +@dataclass(frozen=True) +class UpdateCheck: + current: str + latest: str + update_available: bool + tarball_url: str | None + sha256_url: str | None + + +def read_current_version() -> str: + """Return the string in /VERSION, or "dev" if it can't be read.""" + try: + return (current_symlink() / "VERSION").read_text().strip() or "dev" + except (FileNotFoundError, NotADirectoryError, OSError): + return "dev" + + +def _forgejo_api(path: str) -> dict: + url = f"https://{FORGEJO_HOST}/api/v1/repos/{FORGEJO_REPO}{path}" + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read()) + except (urllib.error.URLError, json.JSONDecodeError) as e: + raise UpdateError(f"forgejo api {url}: {e}") from e + + +def _version_tuple(v: str) -> tuple: + """Compare CalVer tags like 26.1-alpha < 26.1-beta < 26.1 < 26.2-alpha. + + The "stable" release (no suffix) sorts after its own pre-releases. Uses a + tuple of (year, release, stage-rank, stage-tag). Stage rank: alpha=0, + beta=1, rc=2, stable=3, unknown=-1. + """ + stage_rank = {"alpha": 0, "beta": 1, "rc": 2} + head, _, suffix = v.partition("-") + try: + year_str, release_str = head.split(".", 1) + year = int(year_str) + release = int(release_str) + except (ValueError, IndexError): + return (-1, -1, -1, v) + if not suffix: + return (year, release, 3, "") + for name, rank in stage_rank.items(): + if suffix.startswith(name): + return (year, release, rank, suffix) + return (year, release, -1, suffix) + + +def check_update() -> UpdateCheck: + """Return current + latest versions and whether an update is available.""" + current = read_current_version() + release = _forgejo_api("/releases/latest") + latest = str(release.get("tag_name") or "").strip() + if not latest: + raise UpdateError("no latest release (empty tag_name)") + tarball_url = None + sha256_url = None + for asset in release.get("assets") or []: + name = asset.get("name") or "" + url = asset.get("browser_download_url") or "" + if name.endswith(".tar.gz") and "furtka-" in name: + tarball_url = url + elif name.endswith(".tar.gz.sha256"): + sha256_url = url + available = latest != current and _version_tuple(latest) > _version_tuple(current) + return UpdateCheck( + current=current, + latest=latest, + update_available=available, + tarball_url=tarball_url, + sha256_url=sha256_url, + ) + + +def _download(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + req = urllib.request.Request(url) + try: + with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f: + shutil.copyfileobj(resp, f) + except urllib.error.URLError as e: + raise UpdateError(f"download {url}: {e}") from e + + +def _sha256_of(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def verify_tarball(tarball: Path, expected_sha: str) -> None: + actual = _sha256_of(tarball) + if actual != expected_sha: + raise UpdateError(f"sha256 mismatch: expected {expected_sha}, got {actual}") + + +def _parse_sha256_sidecar(text: str) -> str: + """Extract the hash from a standard `sha256sum` sidecar line.""" + line = text.strip().split("\n", 1)[0].strip() + if not line: + raise UpdateError("empty sha256 sidecar") + return line.split()[0] + + +def _extract_tarball(tarball: Path, dest: Path) -> str: + """Extract the tarball and return the VERSION read from its root.""" + dest.mkdir(parents=True, exist_ok=True) + with tarfile.open(tarball, "r:gz") as tf: + # defensive: refuse entries that would escape dest + for member in tf.getmembers(): + if member.name.startswith(("/", "..")) or ".." in Path(member.name).parts: + raise UpdateError(f"refusing tarball entry {member.name!r}") + tf.extractall(dest) + version_file = dest / "VERSION" + if not version_file.is_file(): + raise UpdateError("tarball has no VERSION file at root") + return version_file.read_text().strip() + + +def write_state(stage: str, **extra) -> None: + state_path().parent.mkdir(parents=True, exist_ok=True) + tmp = state_path().with_suffix(".tmp") + payload = {"stage": stage, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), **extra} + tmp.write_text(json.dumps(payload, indent=2)) + tmp.replace(state_path()) + + +def read_state() -> dict: + try: + return json.loads(state_path().read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def acquire_lock(): + path = lock_path() + path.parent.mkdir(parents=True, exist_ok=True) + fh = path.open("w") + try: + fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as e: + fh.close() + raise UpdateError("another update is already in progress") from e + return fh + + +def _run(cmd: list[str]) -> None: + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + if proc.returncode != 0: + raise UpdateError( + f"{' '.join(cmd)} exited {proc.returncode}: {(proc.stderr or proc.stdout).strip()}" + ) + + +def _health_check(url: str, deadline_s: float = 30.0) -> bool: + end = time.time() + deadline_s + while time.time() < end: + try: + with urllib.request.urlopen(url, timeout=3) as resp: + if resp.status == 200: + return True + except urllib.error.URLError: + pass + time.sleep(1) + return False + + +def prepare_update(check: UpdateCheck, download_dir: Path | None = None) -> tuple[Path, str]: + """Download + verify the tarball. Returns (tarball_path, version).""" + if not check.tarball_url or not check.sha256_url: + raise UpdateError("release is missing tarball or sha256 asset") + dl_dir = download_dir or (_STATE_DIR / "updates") + dl_dir.mkdir(parents=True, exist_ok=True) + tarball = dl_dir / f"furtka-{check.latest}.tar.gz" + sha_file = dl_dir / f"furtka-{check.latest}.tar.gz.sha256" + write_state("downloading", latest=check.latest) + _download(check.tarball_url, tarball) + _download(check.sha256_url, sha_file) + write_state("verifying", latest=check.latest) + expected = _parse_sha256_sidecar(sha_file.read_text()) + verify_tarball(tarball, expected) + return tarball, check.latest + + +def apply_update(tarball: Path, version: str) -> None: + """Extract, flip the symlink, restart services. Raises on failure. + + Caller is expected to have verified the sha256 already — but we re-check + here against the on-disk file anyway (TOCTOU). + """ + current = current_symlink() + versions = versions_dir() + versions.mkdir(parents=True, exist_ok=True) + + write_state("extracting", latest=version) + staging = versions / f"_staging-{version}" + if staging.exists(): + shutil.rmtree(staging) + actual_version = _extract_tarball(tarball, staging) + if actual_version != version: + shutil.rmtree(staging, ignore_errors=True) + raise UpdateError(f"tarball VERSION ({actual_version}) doesn't match expected ({version})") + target = versions / version + if target.exists(): + shutil.rmtree(target) + staging.rename(target) + + write_state("swapping", latest=version) + previous = None + if current.is_symlink(): + previous = os.readlink(current) + current.unlink() + try: + current.symlink_to(target) + except OSError as e: + if previous: + current.symlink_to(previous) + raise UpdateError(f"symlink swap failed: {e}") from e + + write_state("restarting", latest=version) + try: + _run(["systemctl", "reload", "caddy"]) + _run(["systemctl", "daemon-reload"]) + _run(["systemctl", "try-restart", "furtka-reconcile.service"]) + _run(["systemctl", "restart", "furtka-api.service"]) + except UpdateError as e: + _rollback(previous, version, f"service restart failed: {e}") + raise + + write_state("verifying", latest=version) + ok = _health_check("http://127.0.0.1:7000/api/apps", deadline_s=30.0) + if not ok: + _rollback(previous, version, "health check failed after restart") + raise UpdateError("new version failed health check — rolled back") + + write_state("done", version=version) + + +def _rollback(previous_target: str | None, failed_version: str, reason: str) -> None: + current = current_symlink() + if previous_target: + if current.is_symlink(): + current.unlink() + current.symlink_to(previous_target) + # Best-effort restart on the previous target — if it fails too the + # box is in a hard state, but we can only surface the reason. + subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False) + write_state( + "rolled_back", + failed_version=failed_version, + restored_to=previous_target or "(none)", + reason=reason, + ) + + +def run_update() -> UpdateCheck: + """End-to-end user-initiated update. Blocks on the lock. + + Returns the UpdateCheck so callers can see what happened. Re-raises + UpdateError on any failure; the state file records the stage. + """ + with acquire_lock(): + check = check_update() + if not check.update_available: + write_state("done", version=check.current, note="already up to date") + return check + tarball, version = prepare_update(check) + apply_update(tarball, version) + return check + + +def rollback() -> str: + """Roll back to the most recent non-current version slot. Returns the + version we rolled back to, or raises if nothing to roll back to.""" + current = current_symlink() + if not current.is_symlink(): + raise UpdateError("/opt/furtka/current is not a symlink — can't roll back") + current_target = Path(os.readlink(current)).name + slots = sorted( + (p.name for p in versions_dir().iterdir() if p.is_dir() and not p.name.startswith("_")), + key=_version_tuple, + reverse=True, + ) + candidates = [s for s in slots if s != current_target] + if not candidates: + raise UpdateError("no other version slots available to roll back to") + target_name = candidates[0] + target = versions_dir() / target_name + current.unlink() + current.symlink_to(target) + subprocess.run(["systemctl", "daemon-reload"], check=False) + subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False) + write_state("rolled_back_manual", restored_to=target_name) + return target_name diff --git a/iso/build.sh b/iso/build.sh index 326dab9..56923ca 100755 --- a/iso/build.sh +++ b/iso/build.sh @@ -82,12 +82,18 @@ rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__" # Pack the resource manager (furtka/ Python package + bundled apps/) as a # tarball that webinstaller hands to archinstall via custom_commands. Lives at # a fixed path in the live ISO; the installed system reads it back, untars -# into /opt/furtka/, and gets a working `furtka` CLI + the fileshare app. +# into /opt/furtka/versions//, and gets a working `furtka` CLI + the +# fileshare app. Same tarball shape as Phase-2 self-update releases, so an +# ISO-installed box and an updated box converge on the same layout. echo "==> Bundling resource manager payload" PAYLOAD_STAGE="$(mktemp -d)" cp -a "$REPO_ROOT/furtka" "$PAYLOAD_STAGE/" cp -a "$REPO_ROOT/apps" "$PAYLOAD_STAGE/" find "$PAYLOAD_STAGE" -type d -name __pycache__ -exec rm -rf {} + +# VERSION at tarball root: the installer reads it to choose the versions// +# directory name and /opt/furtka/current/VERSION reports it at runtime. +grep -E '^version = ' "$REPO_ROOT/pyproject.toml" | head -1 \ + | sed 's/.*= "\(.*\)"/\1/' > "$PAYLOAD_STAGE/VERSION" tar -czf "$PROFILE_WORK/airootfs/opt/furtka-resource-manager.tar.gz" \ -C "$PAYLOAD_STAGE" . rm -rf "$PAYLOAD_STAGE" diff --git a/scripts/build-release-tarball.sh b/scripts/build-release-tarball.sh new file mode 100755 index 0000000..e71b795 --- /dev/null +++ b/scripts/build-release-tarball.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Build a Furtka release tarball + sha256 sidecar + release.json metadata. +# +# Usage: ./scripts/build-release-tarball.sh +# +# Produces (in ./dist/): +# furtka-.tar.gz contents extract to /opt/furtka/versions// +# furtka-.tar.gz.sha256 single-line sha256 ( ) +# release.json {"version","sha256","size","created_at"} +# +# The tarball shape matches what iso/build.sh ships in the live ISO: a +# VERSION file at the root, plus furtka/ and apps/ trees. Self-update on a +# running box downloads this tarball, verifies the sha256, stages to +# /opt/furtka/versions//, and flips /opt/furtka/current to it. +set -euo pipefail + +VERSION="${1:?usage: $0 }" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="$REPO_ROOT/dist" + +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +cp -a "$REPO_ROOT/furtka" "$STAGE/" +cp -a "$REPO_ROOT/apps" "$STAGE/" +find "$STAGE" -type d -name __pycache__ -exec rm -rf {} + +echo "$VERSION" > "$STAGE/VERSION" + +mkdir -p "$DIST_DIR" +TARBALL="$DIST_DIR/furtka-$VERSION.tar.gz" +tar -czf "$TARBALL" -C "$STAGE" . + +SHA=$(sha256sum "$TARBALL" | awk '{print $1}') +SIZE=$(stat -c%s "$TARBALL") + +printf '%s %s\n' "$SHA" "$(basename "$TARBALL")" > "$TARBALL.sha256" + +cat > "$DIST_DIR/release.json" < +# +# Preconditions: +# - $FORGEJO_TOKEN set (PAT with write:repository) +# - dist/furtka-.tar.gz + .sha256 + release.json already built +# +# Behaviour: +# 1. Read the [] section from CHANGELOG.md for the release body. +# 2. Create a release on Forgejo (or fail if one already exists for the tag). +# 3. Upload the three assets sequentially (Forgejo's release API has been +# observed to choke on parallel uploads). +set -euo pipefail + +VERSION="${1:?usage: $0 }" +: "${FORGEJO_TOKEN:?FORGEJO_TOKEN must be set}" + +HOST="${FORGEJO_HOST:-forgejo.sourcegate.online}" +REPO="${FORGEJO_REPO:-daniel/furtka}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="$REPO_ROOT/dist" +TARBALL="$DIST_DIR/furtka-$VERSION.tar.gz" +SHA_FILE="$TARBALL.sha256" +RELEASE_JSON="$DIST_DIR/release.json" + +for f in "$TARBALL" "$SHA_FILE" "$RELEASE_JSON"; do + [ -f "$f" ] || { echo "missing: $f"; exit 1; } +done + +# Extract the changelog section for this version. Matches `## []` +# up to (but not including) the next `## [` line. +BODY="$(awk -v v="$VERSION" ' + BEGIN { inside=0 } + /^## \[/ { + if (inside) exit + if (index($0, "[" v "]") > 0) { inside=1; next } + } + inside { print } +' "$REPO_ROOT/CHANGELOG.md")" +if [ -z "$BODY" ]; then + BODY="Release $VERSION" +fi + +PRERELEASE=false +if [[ "$VERSION" == *-alpha* || "$VERSION" == *-beta* || "$VERSION" == *-rc* ]]; then + PRERELEASE=true +fi + +api() { + curl --silent --show-error --fail-with-body \ + --header "Authorization: token $FORGEJO_TOKEN" \ + "$@" +} + +base="https://$HOST/api/v1/repos/$REPO" + +# 1. Create the release. +release_body_json="$(jq -n \ + --arg tag "$VERSION" \ + --arg name "$VERSION" \ + --arg body "$BODY" \ + --argjson pre "$PRERELEASE" \ + '{tag_name: $tag, name: $name, body: $body, prerelease: $pre}')" + +echo "==> Creating release $VERSION" +release_response="$(api --request POST "$base/releases" \ + --header "Content-Type: application/json" \ + --data "$release_body_json")" + +release_id="$(echo "$release_response" | jq -r '.id')" +if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then + echo "error: couldn't parse release id from response:" + echo "$release_response" + exit 1 +fi +echo " release id: $release_id" + +# 2. Upload assets — one at a time. +upload_asset() { + local path="$1" + local name + name="$(basename "$path")" + echo "==> Uploading $name" + api --request POST "$base/releases/$release_id/assets?name=$name" \ + --form "attachment=@$path" > /dev/null +} + +upload_asset "$TARBALL" +upload_asset "$SHA_FILE" +upload_asset "$RELEASE_JSON" + +echo "Release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION" diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..03e6ece --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,244 @@ +"""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")) + # 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 + + +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" + _make_tarball(tar, "26.1-alpha") + + # 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" + 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" + + +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. + 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", + }, + ], + }, + ) + 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": []}, + ) + check = updater.check_update() + assert check.update_available is False diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py index 3520879..dc7dd91 100644 --- a/tests/test_webinstaller_assets.py +++ b/tests/test_webinstaller_assets.py @@ -136,11 +136,3 @@ def test_read_asset_raises_for_missing_file(): def test_assets_dir_resolves_to_repo_tree(): assert app._ASSETS_DIR == ASSETS - - -def test_version_asset_matches_pyproject(): - import tomllib - - with open(REPO_ROOT / "pyproject.toml", "rb") as f: - version = tomllib.load(f)["project"]["version"] - assert (ASSETS / "VERSION").read_text().strip() == version