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>
This commit is contained in:
parent
4569c37640
commit
f0acc4427e
10 changed files with 878 additions and 16 deletions
33
.forgejo/workflows/release.yml
Normal file
33
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: Release
|
||||
|
||||
# Tag-triggered: when `git push origin <version>` 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}"
|
||||
13
RELEASING.md
13
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
26.0-alpha
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
371
furtka/updater.py
Normal file
371
furtka/updater.py
Normal file
|
|
@ -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/<ver>/_staging`` and moves it to
|
||||
``versions/<ver>/`` 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 <current>/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
|
||||
|
|
@ -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/<VERSION>/, 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/<ver>/
|
||||
# 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"
|
||||
|
|
|
|||
49
scripts/build-release-tarball.sh
Executable file
49
scripts/build-release-tarball.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build a Furtka release tarball + sha256 sidecar + release.json metadata.
|
||||
#
|
||||
# Usage: ./scripts/build-release-tarball.sh <version>
|
||||
#
|
||||
# Produces (in ./dist/):
|
||||
# furtka-<version>.tar.gz contents extract to /opt/furtka/versions/<version>/
|
||||
# furtka-<version>.tar.gz.sha256 single-line sha256 (<hash> <name>)
|
||||
# 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/<version>/, and flips /opt/furtka/current to it.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?usage: $0 <version>}"
|
||||
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" <<EOF
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"sha256": "$SHA",
|
||||
"size": $SIZE,
|
||||
"created_at": "$(date -Iseconds)"
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "Built $TARBALL"
|
||||
echo " sha256: $SHA"
|
||||
echo " size: $SIZE bytes"
|
||||
95
scripts/publish-release.sh
Executable file
95
scripts/publish-release.sh
Executable file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env bash
|
||||
# Publish a Furtka release to the Forgejo releases page.
|
||||
#
|
||||
# Usage: ./scripts/publish-release.sh <version>
|
||||
#
|
||||
# Preconditions:
|
||||
# - $FORGEJO_TOKEN set (PAT with write:repository)
|
||||
# - dist/furtka-<version>.tar.gz + .sha256 + release.json already built
|
||||
#
|
||||
# Behaviour:
|
||||
# 1. Read the [<version>] 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 <version>}"
|
||||
: "${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 `## [<version>]`
|
||||
# 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"
|
||||
244
tests/test_updater.py
Normal file
244
tests/test_updater.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue