New `furtka catalog sync` pulls the latest daniel/furtka-apps release,
verifies its sha256, extracts under /var/lib/furtka/catalog/, and
atomically swaps into place — so apps can ship without cutting a new
Furtka core release. A daily timer (furtka-catalog-sync.timer, 10 min
post-boot + 24 h with ±6 h jitter) drives the sync; /apps gets a
manual "Sync apps catalog" button that kicks the same code path via a
detached systemd-run unit.
Layout of the new on-box tree:
/var/lib/furtka/catalog/ synced catalog (survives self-updates)
├── VERSION
└── apps/<name>/ ...
/var/lib/furtka/catalog-state.json sync stage + last version, UI-polled
/run/furtka/catalog.lock flock so timer + manual click can't race
Resolver precedence (furtka/sources.py): catalog wins over the bundled
seed (/opt/furtka/current/apps/, carried by the core release for offline
first-boot). Installed apps under /var/lib/furtka/apps/ are never auto-
swapped — user clicks Reinstall to move an existing install onto a
newer catalog version; settings merge-preserved via the existing
installer.install_from path.
New files:
- furtka/_release_common.py — shared Forgejo/tarball primitives lifted
from furtka/updater.py. Both modules now import from here; updater's
behaviour and public API unchanged.
- furtka/catalog.py — check_catalog(), sync_catalog() with staging +
manifest validation + atomic rename. Refuses bad sha256 / broken
manifests and leaves the live catalog intact on any failure path.
- furtka/sources.py — resolve_app_name() / list_available() abstraction
used by installer.resolve_source and api._list_available.
- assets/systemd/furtka-catalog-sync.{service,timer} — oneshot service
+ daily timer. Timer auto-enables on self-update via a one-line
addition to _link_new_units (fresh installs get enabled via the
webinstaller's _FURTKA_UNITS list).
API + UI:
- /api/bundled renamed internally to _list_available; endpoint stays as
a backcompat alias; /api/apps/available is the new canonical name.
Each list entry carries a `source` field ("catalog" | "bundled").
- POST /api/catalog/sync/check + /apply + GET /api/catalog/status.
- /apps page grows a catalog-status row + Sync button; poll loop
mirrors the Furtka self-update flow.
CLI: `furtka catalog sync [--check]` + `furtka catalog status` (both
support --json). Old `furtka app install` / `reconcile` / `update` /
`rollback` surfaces are unchanged.
Test gate: 194/170 baseline + 24 new tests covering catalog sync
(happy path, sha256 mismatch, invalid manifest, lock contention,
preserves-on-failure) + resolver precedence + api renames. ruff
check + format clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
253 lines
9.3 KiB
Python
253 lines
9.3 KiB
Python
"""Furtka apps catalog sync.
|
|
|
|
Mirrors the shape of ``furtka.updater`` but targets a separate Forgejo
|
|
repo (``daniel/furtka-apps`` by default) whose releases carry a single
|
|
``furtka-apps-<ver>.tar.gz`` with ``VERSION`` at the root and an
|
|
``apps/<name>/`` tree underneath. Pulling the catalog keeps the on-box
|
|
app ecosystem fresh without requiring a Furtka core release — core
|
|
ships a seed ``apps/`` under ``/opt/furtka/current/apps/`` that the
|
|
resolver falls back to when the catalog is empty or stale.
|
|
|
|
Flow of ``sync_catalog()``:
|
|
|
|
1. flock on ``/run/furtka/catalog.lock`` so two triggers (timer + manual
|
|
UI click) can't race.
|
|
2. ``check_catalog()`` asks Forgejo for the latest release and picks out
|
|
the tarball + sidecar URLs.
|
|
3. Download tarball + sidecar to ``/var/lib/furtka/catalog/_downloads/``.
|
|
4. Verify the sha256 sidecar against the tarball.
|
|
5. Extract into ``/var/lib/furtka/catalog/_staging/``.
|
|
6. Validate every ``apps/<name>/manifest.json`` via ``furtka.manifest.
|
|
load_manifest``. A broken catalog release is refused here, not half-
|
|
applied.
|
|
7. Atomic rename: existing live catalog → ``catalog.prev/``, staging →
|
|
``catalog/``, then rmtree the prev. Any failure before this step
|
|
leaves the live catalog untouched.
|
|
8. Write ``/var/lib/furtka/catalog-state.json`` for the UI.
|
|
|
|
Paths can be overridden via env vars so tests can redirect everything to
|
|
a tmp dir.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import shutil
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from furtka import _release_common as _rc
|
|
from furtka.manifest import ManifestError, load_manifest
|
|
from furtka.paths import catalog_dir
|
|
|
|
FORGEJO_HOST = os.environ.get("FURTKA_FORGEJO_HOST", "forgejo.sourcegate.online")
|
|
CATALOG_REPO = os.environ.get("FURTKA_CATALOG_REPO", "daniel/furtka-apps")
|
|
_CATALOG_STATE = Path(os.environ.get("FURTKA_CATALOG_STATE", "/var/lib/furtka/catalog-state.json"))
|
|
_LOCK_PATH = Path(os.environ.get("FURTKA_CATALOG_LOCK", "/run/furtka/catalog.lock"))
|
|
|
|
_STAGING_NAME = "_staging"
|
|
_DOWNLOADS_NAME = "_downloads"
|
|
_PREV_SUFFIX = ".prev"
|
|
_VERSION_FILE = "VERSION"
|
|
|
|
|
|
class CatalogError(RuntimeError):
|
|
"""Any failure in the catalog sync flow that should surface to the caller."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CatalogCheck:
|
|
current: str | None
|
|
latest: str
|
|
update_available: bool
|
|
tarball_url: str | None
|
|
sha256_url: str | None
|
|
|
|
|
|
def state_path() -> Path:
|
|
return _CATALOG_STATE
|
|
|
|
|
|
def lock_path() -> Path:
|
|
return _LOCK_PATH
|
|
|
|
|
|
def read_current_catalog_version() -> str | None:
|
|
"""Return the string in <catalog_dir>/VERSION, or None if absent / unreadable."""
|
|
try:
|
|
value = (catalog_dir() / _VERSION_FILE).read_text().strip()
|
|
except (FileNotFoundError, NotADirectoryError, OSError):
|
|
return None
|
|
return value or None
|
|
|
|
|
|
def check_catalog() -> CatalogCheck:
|
|
"""Query Forgejo for the latest catalog release.
|
|
|
|
Uses ``/releases?limit=1`` (not ``/releases/latest``) for the same
|
|
reason the core updater does — Forgejo's ``latest`` endpoint skips
|
|
pre-releases and 404s when every tag carries a suffix.
|
|
"""
|
|
current = read_current_catalog_version()
|
|
releases = _rc.forgejo_api(
|
|
FORGEJO_HOST, CATALOG_REPO, "/releases?limit=1", error_cls=CatalogError
|
|
)
|
|
if not isinstance(releases, list) or not releases:
|
|
raise CatalogError("no catalog releases published yet")
|
|
release = releases[0]
|
|
latest = str(release.get("tag_name") or "").strip()
|
|
if not latest:
|
|
raise CatalogError("latest catalog release has 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-apps-" in name:
|
|
tarball_url = url
|
|
elif name.endswith(".tar.gz.sha256"):
|
|
sha256_url = url
|
|
available = latest != current and (
|
|
current is None or _rc.version_tuple(latest) > _rc.version_tuple(current)
|
|
)
|
|
return CatalogCheck(
|
|
current=current,
|
|
latest=latest,
|
|
update_available=available,
|
|
tarball_url=tarball_url,
|
|
sha256_url=sha256_url,
|
|
)
|
|
|
|
|
|
def write_state(stage: str, **extra) -> None:
|
|
"""Atomic JSON state write — same shape as updater's update-state.json."""
|
|
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 CatalogError("another catalog sync is already in progress") from e
|
|
return fh
|
|
|
|
|
|
def _validate_staging(staging: Path, expected_version: str) -> None:
|
|
"""Fail hard if the staging tree isn't a well-formed catalog release."""
|
|
version_file = staging / _VERSION_FILE
|
|
if not version_file.is_file():
|
|
raise CatalogError("catalog tarball has no VERSION file at root")
|
|
actual = version_file.read_text().strip()
|
|
if actual != expected_version:
|
|
raise CatalogError(
|
|
f"catalog tarball VERSION ({actual!r}) doesn't match expected ({expected_version!r})"
|
|
)
|
|
apps_root = staging / "apps"
|
|
if not apps_root.is_dir():
|
|
raise CatalogError("catalog tarball has no apps/ directory")
|
|
for entry in sorted(apps_root.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
manifest_path = entry / "manifest.json"
|
|
if not manifest_path.exists():
|
|
raise CatalogError(f"catalog app {entry.name!r} has no manifest.json")
|
|
try:
|
|
load_manifest(manifest_path, expected_name=entry.name)
|
|
except ManifestError as e:
|
|
raise CatalogError(f"catalog app {entry.name!r}: invalid manifest: {e}") from e
|
|
|
|
|
|
def _atomic_swap(staging: Path) -> None:
|
|
"""Move staging → live catalog, keeping the previous tree as .prev until
|
|
the rename succeeds so we never leave a half-written catalog on disk."""
|
|
live = catalog_dir()
|
|
live.parent.mkdir(parents=True, exist_ok=True)
|
|
prev = live.with_name(live.name + _PREV_SUFFIX)
|
|
if prev.exists():
|
|
shutil.rmtree(prev)
|
|
if live.exists():
|
|
live.rename(prev)
|
|
try:
|
|
staging.rename(live)
|
|
except OSError as e:
|
|
if prev.exists():
|
|
# try to restore the previous tree; if that also fails the box
|
|
# has no catalog at all until the next sync — still better than
|
|
# a partially-extracted tree.
|
|
try:
|
|
prev.rename(live)
|
|
except OSError:
|
|
pass
|
|
raise CatalogError(f"atomic catalog swap failed: {e}") from e
|
|
if prev.exists():
|
|
shutil.rmtree(prev, ignore_errors=True)
|
|
|
|
|
|
def sync_catalog() -> CatalogCheck:
|
|
"""End-to-end sync. Acquires the lock, writes state at each stage, and
|
|
leaves the live catalog untouched on any failure before the rename step.
|
|
"""
|
|
with acquire_lock():
|
|
write_state("checking")
|
|
check = check_catalog()
|
|
if not check.update_available:
|
|
write_state("done", version=check.current or check.latest, note="already up to date")
|
|
return check
|
|
if not check.tarball_url or not check.sha256_url:
|
|
raise CatalogError("catalog release is missing tarball or sha256 asset")
|
|
|
|
# Downloads land in a sibling of the live catalog so half-finished
|
|
# artefacts never pollute the live tree, and stay under /var/lib/
|
|
# furtka/ so a sync interrupted by reboot can resume instead of
|
|
# starting over from /tmp (which clears).
|
|
dl_dir = catalog_dir().with_name(catalog_dir().name + _DOWNLOADS_NAME)
|
|
dl_dir.mkdir(parents=True, exist_ok=True)
|
|
tarball = dl_dir / f"furtka-apps-{check.latest}.tar.gz"
|
|
sha_file = dl_dir / f"furtka-apps-{check.latest}.tar.gz.sha256"
|
|
|
|
write_state("downloading", latest=check.latest)
|
|
_rc.download(check.tarball_url, tarball, error_cls=CatalogError)
|
|
_rc.download(check.sha256_url, sha_file, error_cls=CatalogError)
|
|
|
|
write_state("verifying", latest=check.latest)
|
|
expected = _rc.parse_sha256_sidecar(sha_file.read_text(), error_cls=CatalogError)
|
|
_rc.verify_tarball(tarball, expected, error_cls=CatalogError)
|
|
|
|
write_state("extracting", latest=check.latest)
|
|
staging = catalog_dir().with_name(catalog_dir().name + _STAGING_NAME)
|
|
if staging.exists():
|
|
shutil.rmtree(staging)
|
|
try:
|
|
_rc.extract_tarball(tarball, staging, error_cls=CatalogError)
|
|
_validate_staging(staging, check.latest)
|
|
except CatalogError:
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
raise
|
|
|
|
write_state("swapping", latest=check.latest)
|
|
try:
|
|
_atomic_swap(staging)
|
|
except CatalogError:
|
|
shutil.rmtree(staging, ignore_errors=True)
|
|
raise
|
|
|
|
write_state("done", version=check.latest, previous=check.current)
|
|
return check
|