feat(furtka): resource-manager skeleton — manifest, scanner, CLI
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>
2026-04-15 09:59:41 +02:00
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from furtka.manifest import Manifest, ManifestError, load_manifest
|
|
|
|
|
|
|
|
|
|
VALID_MANIFEST = {
|
|
|
|
|
"name": "fileshare",
|
|
|
|
|
"display_name": "Network Files",
|
|
|
|
|
"version": "0.1.0",
|
|
|
|
|
"description": "SMB share",
|
|
|
|
|
"volumes": ["files"],
|
|
|
|
|
"ports": [445],
|
|
|
|
|
"icon": "icon.svg",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_app(tmp_path, name, payload):
|
|
|
|
|
app_dir = tmp_path / name
|
|
|
|
|
app_dir.mkdir()
|
|
|
|
|
(app_dir / "manifest.json").write_text(json.dumps(payload))
|
|
|
|
|
return app_dir / "manifest.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_valid_manifest(tmp_path):
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
assert isinstance(m, Manifest)
|
|
|
|
|
assert m.name == "fileshare"
|
|
|
|
|
assert m.volumes == ("files",)
|
|
|
|
|
assert m.ports == (445,)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_volume_namespacing(tmp_path):
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
assert m.volume_name("files") == "furtka_fileshare_files"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_volume_raises(tmp_path):
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
with pytest.raises(ManifestError):
|
|
|
|
|
m.volume_name("does-not-exist")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_required_field(tmp_path):
|
|
|
|
|
bad = dict(VALID_MANIFEST)
|
|
|
|
|
del bad["display_name"]
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="display_name"):
|
|
|
|
|
load_manifest(path)
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 10:17:00 +02:00
|
|
|
def test_name_must_match_when_expected_name_given(tmp_path):
|
|
|
|
|
# Scanner passes expected_name=<folder name> so /var/lib/furtka/apps/X/
|
|
|
|
|
# can't lie about its own identity.
|
feat(furtka): resource-manager skeleton — manifest, scanner, CLI
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>
2026-04-15 09:59:41 +02:00
|
|
|
path = _write_app(tmp_path, "wrong-folder", VALID_MANIFEST)
|
2026-04-15 10:17:00 +02:00
|
|
|
with pytest.raises(ManifestError, match="must equal 'wrong-folder'"):
|
|
|
|
|
load_manifest(path, expected_name="wrong-folder")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_name_check_skipped_without_expected_name(tmp_path):
|
|
|
|
|
# Installer loads from arbitrary source paths (e.g. /tmp/my-tweaked-app/)
|
|
|
|
|
# — the source folder name shouldn't matter, only the manifest's own name.
|
|
|
|
|
path = _write_app(tmp_path, "any-folder-name", VALID_MANIFEST)
|
|
|
|
|
m = load_manifest(path)
|
|
|
|
|
assert m.name == "fileshare"
|
feat(furtka): resource-manager skeleton — manifest, scanner, CLI
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>
2026-04-15 09:59:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalid_json(tmp_path):
|
|
|
|
|
app = tmp_path / "fileshare"
|
|
|
|
|
app.mkdir()
|
|
|
|
|
(app / "manifest.json").write_text("{not json")
|
|
|
|
|
with pytest.raises(ManifestError, match="invalid JSON"):
|
|
|
|
|
load_manifest(app / "manifest.json")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_volumes_wrong_type(tmp_path):
|
|
|
|
|
bad = dict(VALID_MANIFEST, volumes="files")
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="volumes"):
|
|
|
|
|
load_manifest(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ports_wrong_type(tmp_path):
|
|
|
|
|
bad = dict(VALID_MANIFEST, ports=["445"])
|
|
|
|
|
path = _write_app(tmp_path, "fileshare", bad)
|
|
|
|
|
with pytest.raises(ManifestError, match="ports"):
|
|
|
|
|
load_manifest(path)
|