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>
108 lines
3.3 KiB
Python
108 lines
3.3 KiB
Python
"""Tests for the catalog > bundled resolver."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
def _manifest(name: str = "fileshare") -> dict:
|
|
return {
|
|
"name": name,
|
|
"display_name": "Fileshare",
|
|
"version": "0.1.0",
|
|
"description": "x",
|
|
"volumes": [],
|
|
"ports": [],
|
|
"icon": "icon.svg",
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def sources_mod(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "catalog"))
|
|
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(tmp_path / "bundled"))
|
|
|
|
import importlib
|
|
|
|
from furtka import paths as p
|
|
from furtka import sources as s
|
|
|
|
importlib.reload(p)
|
|
importlib.reload(s)
|
|
return s
|
|
|
|
|
|
def _seed_app(root: Path, name: str, manifest: dict | None = None) -> Path:
|
|
folder = root / name
|
|
folder.mkdir(parents=True)
|
|
(folder / "manifest.json").write_text(json.dumps(manifest or _manifest(name)))
|
|
return folder
|
|
|
|
|
|
def test_resolve_app_name_returns_none_when_absent(sources_mod):
|
|
assert sources_mod.resolve_app_name("nope") is None
|
|
|
|
|
|
def test_resolve_app_name_prefers_catalog_over_bundled(sources_mod, tmp_path):
|
|
_seed_app(tmp_path / "catalog" / "apps", "fileshare")
|
|
_seed_app(tmp_path / "bundled", "fileshare")
|
|
|
|
result = sources_mod.resolve_app_name("fileshare")
|
|
assert result is not None
|
|
assert result.origin == "catalog"
|
|
assert result.path.parent.name == "apps"
|
|
assert result.path.parent.parent.name == "catalog"
|
|
|
|
|
|
def test_resolve_app_name_falls_back_to_bundled(sources_mod, tmp_path):
|
|
_seed_app(tmp_path / "bundled", "fileshare")
|
|
|
|
result = sources_mod.resolve_app_name("fileshare")
|
|
assert result is not None
|
|
assert result.origin == "bundled"
|
|
|
|
|
|
def test_resolve_app_name_ignores_folder_without_manifest(sources_mod, tmp_path):
|
|
# Empty folder is not a valid app even if the name matches.
|
|
(tmp_path / "catalog" / "apps" / "fileshare").mkdir(parents=True)
|
|
_seed_app(tmp_path / "bundled", "fileshare")
|
|
|
|
result = sources_mod.resolve_app_name("fileshare")
|
|
# Catalog entry without manifest is skipped; bundled wins.
|
|
assert result.origin == "bundled"
|
|
|
|
|
|
def test_list_available_unions_catalog_and_bundled(sources_mod, tmp_path):
|
|
_seed_app(tmp_path / "catalog" / "apps", "fileshare")
|
|
_seed_app(tmp_path / "bundled", "otherapp")
|
|
|
|
names = {s.path.name: s.origin for s in sources_mod.list_available()}
|
|
assert names == {"fileshare": "catalog", "otherapp": "bundled"}
|
|
|
|
|
|
def test_list_available_catalog_wins_on_collision(sources_mod, tmp_path):
|
|
_seed_app(tmp_path / "catalog" / "apps", "fileshare")
|
|
_seed_app(tmp_path / "bundled", "fileshare")
|
|
|
|
entries = sources_mod.list_available()
|
|
assert len(entries) == 1
|
|
assert entries[0].origin == "catalog"
|
|
|
|
|
|
def test_list_available_empty_when_neither_exists(sources_mod):
|
|
assert sources_mod.list_available() == []
|
|
|
|
|
|
def test_list_available_skips_non_dirs_and_no_manifest(sources_mod, tmp_path):
|
|
# A plain file in catalog/apps and an empty dir in bundled — both ignored.
|
|
cat_root = tmp_path / "catalog" / "apps"
|
|
cat_root.mkdir(parents=True)
|
|
(cat_root / "not-a-dir.txt").write_text("x")
|
|
(tmp_path / "bundled" / "emptyapp").mkdir(parents=True)
|
|
_seed_app(tmp_path / "bundled", "realapp")
|
|
|
|
entries = sources_mod.list_available()
|
|
assert [e.path.name for e in entries] == ["realapp"]
|