Slice 1 of the Resource Manager (see docs/resource-manager.md + plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the read-only half: a JSON manifest schema with namespacing, a scanner that walks /var/lib/furtka/apps/, and a `furtka` CLI with `app list` and `reconcile --dry-run`. Reconciler / volume creation / docker compose calls land in the next slice. - furtka.manifest: dataclass + load_manifest with required-field + type validation. volume_name() injects the furtka_<app>_<vol> namespace so apps can each declare a "data" volume without colliding. - furtka.scanner: tolerant — broken manifest = ScanResult with error, not an exception. Lets reconcile log + skip rather than abort. - furtka.cli: text + --json output. argparse with `app list` and `reconcile --dry-run`. main() returns int for clean exit codes. - furtka.paths: FURTKA_APPS_DIR env override so tests don't need root. - 19 new tests covering valid manifests, every validation branch, scanner edge cases (missing root, broken manifest, sort order), and the CLI subcommands. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
69 lines
1.8 KiB
Python
69 lines
1.8 KiB
Python
import json
|
|
|
|
from furtka.scanner import scan
|
|
|
|
VALID_MANIFEST = {
|
|
"name": "fileshare",
|
|
"display_name": "Network Files",
|
|
"version": "0.1.0",
|
|
"description": "SMB share",
|
|
"volumes": ["files"],
|
|
"ports": [445],
|
|
"icon": "icon.svg",
|
|
}
|
|
|
|
|
|
def _make_app(root, name, manifest=None):
|
|
app = root / name
|
|
app.mkdir(parents=True)
|
|
if manifest is not None:
|
|
(app / "manifest.json").write_text(json.dumps(manifest))
|
|
return app
|
|
|
|
|
|
def test_scan_missing_root(tmp_path):
|
|
assert scan(tmp_path / "does-not-exist") == []
|
|
|
|
|
|
def test_scan_empty_root(tmp_path):
|
|
assert scan(tmp_path) == []
|
|
|
|
|
|
def test_scan_valid_app(tmp_path):
|
|
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
results = scan(tmp_path)
|
|
assert len(results) == 1
|
|
assert results[0].ok
|
|
assert results[0].manifest.name == "fileshare"
|
|
|
|
|
|
def test_scan_skips_files(tmp_path):
|
|
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
(tmp_path / "stray.txt").write_text("ignore me")
|
|
results = scan(tmp_path)
|
|
assert len(results) == 1
|
|
|
|
|
|
def test_scan_missing_manifest(tmp_path):
|
|
_make_app(tmp_path, "broken")
|
|
results = scan(tmp_path)
|
|
assert len(results) == 1
|
|
assert not results[0].ok
|
|
assert "manifest.json missing" in results[0].error
|
|
|
|
|
|
def test_scan_invalid_manifest(tmp_path):
|
|
bad = dict(VALID_MANIFEST)
|
|
del bad["volumes"]
|
|
_make_app(tmp_path, "fileshare", bad)
|
|
results = scan(tmp_path)
|
|
assert len(results) == 1
|
|
assert not results[0].ok
|
|
assert "volumes" in results[0].error
|
|
|
|
|
|
def test_scan_sorted_by_name(tmp_path):
|
|
_make_app(tmp_path, "z-app", dict(VALID_MANIFEST, name="z-app"))
|
|
_make_app(tmp_path, "a-app", dict(VALID_MANIFEST, name="a-app"))
|
|
results = scan(tmp_path)
|
|
assert [r.path.name for r in results] == ["a-app", "z-app"]
|