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>
35 lines
1,020 B
Python
35 lines
1,020 B
Python
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from furtka.manifest import Manifest, ManifestError, load_manifest
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ScanResult:
|
|
path: Path
|
|
manifest: Manifest | None
|
|
error: str | None
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.manifest is not None
|
|
|
|
|
|
def scan(apps_root: Path) -> list[ScanResult]:
|
|
if not apps_root.exists():
|
|
return []
|
|
out: list[ScanResult] = []
|
|
for entry in sorted(apps_root.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
manifest_path = entry / "manifest.json"
|
|
if not manifest_path.exists():
|
|
out.append(ScanResult(path=entry, manifest=None, error="manifest.json missing"))
|
|
continue
|
|
try:
|
|
m = load_manifest(manifest_path)
|
|
except ManifestError as e:
|
|
out.append(ScanResult(path=entry, manifest=None, error=str(e)))
|
|
continue
|
|
out.append(ScanResult(path=entry, manifest=m, error=None))
|
|
return out
|