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
|
|
|
|
|
|
|
|
|
|
from furtka.cli import main
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _set_env(monkeypatch, tmp_path):
|
|
|
|
|
monkeypatch.setenv("FURTKA_APPS_DIR", str(tmp_path))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_app_list_empty(tmp_path, monkeypatch, capsys):
|
|
|
|
|
_set_env(monkeypatch, tmp_path)
|
|
|
|
|
rc = main(["app", "list"])
|
|
|
|
|
assert rc == 0
|
|
|
|
|
assert "no apps installed" in capsys.readouterr().out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_app_list_json_empty(tmp_path, monkeypatch, capsys):
|
|
|
|
|
_set_env(monkeypatch, tmp_path)
|
|
|
|
|
rc = main(["app", "list", "--json"])
|
|
|
|
|
assert rc == 0
|
|
|
|
|
assert json.loads(capsys.readouterr().out) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_app_list_json_with_one_app(tmp_path, monkeypatch, capsys):
|
|
|
|
|
_set_env(monkeypatch, tmp_path)
|
|
|
|
|
app = tmp_path / "fileshare"
|
|
|
|
|
app.mkdir()
|
|
|
|
|
(app / "manifest.json").write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"name": "fileshare",
|
|
|
|
|
"display_name": "Network Files",
|
|
|
|
|
"version": "0.1.0",
|
|
|
|
|
"description": "SMB",
|
|
|
|
|
"volumes": ["files"],
|
|
|
|
|
"ports": [445],
|
|
|
|
|
"icon": "icon.svg",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
rc = main(["app", "list", "--json"])
|
|
|
|
|
assert rc == 0
|
|
|
|
|
data = json.loads(capsys.readouterr().out)
|
|
|
|
|
assert len(data) == 1
|
|
|
|
|
assert data[0]["ok"] is True
|
|
|
|
|
assert data[0]["manifest"]["name"] == "fileshare"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):
|
|
|
|
|
_set_env(monkeypatch, tmp_path)
|
|
|
|
|
rc = main(["reconcile", "--dry-run"])
|
|
|
|
|
assert rc == 0
|
|
|
|
|
out = capsys.readouterr().out
|
2026-04-15 10:02:00 +02:00
|
|
|
assert "0 actions" in out
|