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>
75 lines
2.7 KiB
Python
75 lines
2.7 KiB
Python
"""Single lookup layer for "where does app <name> live right now?".
|
||
|
||
Three origins an app folder can come from:
|
||
|
||
- ``catalog`` — the daily-synced ``/var/lib/furtka/catalog/apps/`` tree
|
||
that ``furtka.catalog.sync_catalog`` maintains.
|
||
- ``bundled`` — the seed ``/opt/furtka/current/apps/`` tree shipped
|
||
inside the core release tarball. Used for first-boot before any
|
||
catalog sync has run, and as the fallback when the catalog is stale,
|
||
missing, or doesn't know about this app.
|
||
- ``local`` — an explicit directory path passed to ``furtka app install
|
||
/path/to/src``; bypasses this module entirely.
|
||
|
||
Catalog wins on collision. The precedence is deliberate — when the user
|
||
pressed "Sync apps catalog" they want what they synced, not whatever the
|
||
core tarball happened to carry.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
from furtka.paths import bundled_apps_dir, catalog_apps_dir
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AppSource:
|
||
path: Path
|
||
origin: str # "catalog" | "bundled" | "local"
|
||
|
||
|
||
def resolve_app_name(name: str) -> AppSource | None:
|
||
"""Return the source folder for a bundled/catalog app name.
|
||
|
||
Checks catalog first, then bundled seed. Presence is tested by
|
||
``manifest.json`` existing — an empty folder or a stray ``.env``
|
||
won't register. Returns ``None`` if the name isn't known anywhere.
|
||
"""
|
||
cat = catalog_apps_dir() / name
|
||
if (cat / "manifest.json").is_file():
|
||
return AppSource(cat, "catalog")
|
||
bundled = bundled_apps_dir() / name
|
||
if (bundled / "manifest.json").is_file():
|
||
return AppSource(bundled, "bundled")
|
||
return None
|
||
|
||
|
||
def list_available() -> list[AppSource]:
|
||
"""Catalog ∪ bundled, catalog wins on name collision.
|
||
|
||
Each entry is a folder containing a manifest.json. Ordering is
|
||
alphabetical by folder name, which matches how the scanner sorts so
|
||
the UI list stays stable across sync/reboot.
|
||
"""
|
||
seen: dict[str, AppSource] = {}
|
||
cat_root = catalog_apps_dir()
|
||
if cat_root.is_dir():
|
||
for entry in sorted(cat_root.iterdir()):
|
||
if not entry.is_dir():
|
||
continue
|
||
if not (entry / "manifest.json").is_file():
|
||
continue
|
||
seen[entry.name] = AppSource(entry, "catalog")
|
||
bundled_root = bundled_apps_dir()
|
||
if bundled_root.is_dir():
|
||
for entry in sorted(bundled_root.iterdir()):
|
||
if not entry.is_dir():
|
||
continue
|
||
if entry.name in seen:
|
||
continue
|
||
if not (entry / "manifest.json").is_file():
|
||
continue
|
||
seen[entry.name] = AppSource(entry, "bundled")
|
||
return [seen[name] for name in sorted(seen)]
|