diff --git a/docs/resource-manager.md b/docs/resource-manager.md new file mode 100644 index 0000000..ed3dcae --- /dev/null +++ b/docs/resource-manager.md @@ -0,0 +1,126 @@ +# Resource Manager — Design Notes + +Scaffold for the conversation between Daniel and Robert. Captures what Robert already sketched in chat on 2026-04-15 and lists the open questions that need an answer before we write code. + +**Status:** Draft 2026-04-15. Nothing implemented. First app to consume this will be a LAN fileshare (SMB/NFS) — that's the forcing function. + +--- + +## What's the Resource Manager? + +The layer between Furtka apps and the underlying system (disk, Docker, network, users). Apps don't touch Docker or the filesystem directly — they declare what they need, the Resource Manager provisions and tracks it. + +Robert's framing (2026-04-15): *"żeby później można było manipulować wszystkimi peryferiami"* — so we can manipulate all peripherals from one place later. + +--- + +## Decided (Robert, 2026-04-15) + +1. **An app is a folder.** A Furtka app is defined as a directory containing: + - a manifest + - a `docker-compose.yaml` + - a `.env` file +2. **A registration script** reads that folder and wires the app into the backend. +3. **Each app gets its own named Docker volume.** Sharing between apps is possible but opt-in, not default. +4. **Filesystem is the source of truth (for now).** No SQL database yet — the Resource Manager reads current state from Docker and the OS ad-hoc. DB gets added when we actually need to store intent the OS doesn't know (e.g. "this share belongs to App X, readable by User Y"). + +These are locked; don't re-litigate without Robert. + +--- + +## Open questions + +These need an answer before the first line of code. + +### Q1 — What's in the manifest? + +This is the contract between app authors and Furtka. Changing it later is expensive. Known candidates: + +- `name` (machine id, must match folder name?) +- `display_name` (shown in UI) +- `version` (semver? calver?) +- `description` +- `volumes` (list of named volumes the app needs) +- `ports` (which ports the app wants exposed; needed for reverse proxy later) +- `icon` (path or URL) +- ... anything else? + +**Format:** YAML / TOML / JSON — not decided. + +### Q2 — Where do apps live on disk? + +Suggested-but-not-decided: `/var/lib/furtka/apps//`. Robert: confirm path? + +### Q3 — What does the registration script actually do? + +Robert said "Skript do rejestrowania w backendzie". Concretely that means some subset of: + +- Validate manifest (schema check, required fields, volume names unique) +- Create any declared named volumes that don't exist yet +- Run `docker compose up -d` inside the app folder +- Record the app in whatever passes for the backend index (right now: nothing — we'd just re-scan the folder each time) + +**Sub-question:** is registration a one-shot on install, or does something (systemd unit?) re-scan `/var/lib/furtka/apps/` on every boot so the filesystem really is authoritative? + +### Q4 — How does the user actually install an app? + +Out of scope for the first cut, but needed to know the shape of the CLI: + +- Admin UI with a button "Install Fileshare"? +- CLI: `furtka app install ` / `` / ``? +- Catalog = a separate repo with app folders? + +### Q5 — Upgrades and removal + +- Upgrade strategy: replace folder + re-run compose? Preserve volumes across upgrades (yes obviously, but needs stating)? +- Removal: does it delete the volume or keep it? Robert's "apki się dzielą" implies a volume can outlive its original app. + +### Q6 — Volume sharing semantics + +Robert: "jak będzie trzeba to będą mogły się dzielić". Open: +- Who requests the share — the sharing app's manifest, or an admin action? +- What's the permission model (read-only, read-write, per-user)? +- This is probably the *first* piece of "intent not reflected in OS state" — might be what finally motivates the DB. + +### Q7 — Backend API surface + +Once the filesystem model is clear, the Resource Manager's backend still needs *some* Python/whatever surface that the web UI and the register script both call. Rough shape: + +- `list_apps()` — scan `/var/lib/furtka/apps/`, return manifests + running status +- `install_app(folder)` — validate + compose up +- `remove_app(name)` — compose down, optionally rm volume +- `list_volumes()` — from Docker +- `list_shares()` — ??? + +Not decided whether this lives in the existing `webinstaller` Flask app, a new backend service, or a thin CLI that the Flask app shells out to. + +--- + +## First consumer — Fileshare + +The point of doing this now is to unblock the fileshare app (see `../memory/project_first_app_fileshare.md` for the conversation context). Walking it through the model above: + +``` +/var/lib/furtka/apps/fileshare/ + manifest.??? # see Q1 — what exactly goes here? + docker-compose.yaml + .env +``` + +- App declares a volume (e.g. `files`). +- Compose runs a Samba/NFS container mounting that volume. +- On Mac/Windows/Android you mount `smb://furtka.local/files`. + +If we can get *this* end-to-end through the Resource Manager, we've validated the whole model with the simplest possible app. Nextcloud, Jellyfin, etc. are the same shape with more knobs. + +--- + +## What we do NOT do yet + +- No database. (Decided.) +- No user/permissions model beyond what Samba/OS already give us. +- No reverse proxy / TLS integration in the manifest. (Tracked separately — see `../memory/project_ssl_local_deferred.md`.) +- No app catalog / store UI. Manual install first. +- No auto-updates. + +These all become easier once Q1–Q7 are answered — adding them to an existing model is straightforward; guessing them now is expensive. diff --git a/furtka/__init__.py b/furtka/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/furtka/cli.py b/furtka/cli.py new file mode 100644 index 0000000..e6637c4 --- /dev/null +++ b/furtka/cli.py @@ -0,0 +1,90 @@ +import argparse +import json +import sys + +from furtka.paths import apps_dir +from furtka.scanner import scan + + +def _cmd_app_list(args: argparse.Namespace) -> int: + results = scan(apps_dir()) + if args.json: + out = [ + { + "path": str(r.path), + "name": r.manifest.name if r.manifest else None, + "ok": r.ok, + "error": r.error, + "manifest": { + "name": r.manifest.name, + "display_name": r.manifest.display_name, + "version": r.manifest.version, + "description": r.manifest.description, + "volumes": list(r.manifest.volumes), + "ports": list(r.manifest.ports), + "icon": r.manifest.icon, + } + if r.manifest + else None, + } + for r in results + ] + print(json.dumps(out, indent=2)) + return 0 + if not results: + print("(no apps installed)") + return 0 + for r in results: + if r.ok: + m = r.manifest + print(f"{m.name:20s} {m.version:10s} {m.display_name}") + else: + print(f"{r.path.name:20s} ERROR {r.error}") + return 0 + + +def _cmd_reconcile(args: argparse.Namespace) -> int: + # Slice 1 stub: report what we see, don't act yet. Reconciler with real + # docker compose calls lands in the next slice. + results = scan(apps_dir()) + print(f"Scanned {apps_dir()}: {len(results)} entries") + for r in results: + status = "ok" if r.ok else f"error: {r.error}" + print(f" - {r.path.name}: {status}") + if not args.dry_run: + print("(reconciler not implemented yet — pass --dry-run to silence this notice)") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="furtka", description="Furtka resource manager") + sub = p.add_subparsers(dest="command", required=True) + + app = sub.add_parser("app", help="Manage installed apps") + app_sub = app.add_subparsers(dest="subcommand", required=True) + + app_list = app_sub.add_parser("list", help="List installed apps") + app_list.add_argument("--json", action="store_true", help="Emit JSON instead of a table") + app_list.set_defaults(func=_cmd_app_list) + + reconcile = sub.add_parser( + "reconcile", + help="Bring docker state in line with /var/lib/furtka/apps", + ) + reconcile.add_argument( + "--dry-run", + action="store_true", + help="Show what would be done without changing anything", + ) + reconcile.set_defaults(func=_cmd_reconcile) + + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/furtka/manifest.py b/furtka/manifest.py new file mode 100644 index 0000000..b37c371 --- /dev/null +++ b/furtka/manifest.py @@ -0,0 +1,73 @@ +import json +from dataclasses import dataclass +from pathlib import Path + +REQUIRED_FIELDS = ( + "name", + "display_name", + "version", + "description", + "volumes", + "ports", + "icon", +) + + +class ManifestError(Exception): + pass + + +@dataclass(frozen=True) +class Manifest: + name: str + display_name: str + version: str + description: str + volumes: tuple[str, ...] + ports: tuple[int, ...] + icon: str + + def volume_name(self, short: str) -> str: + # Namespace volume names so two apps can each declare e.g. "data" + # without colliding in `docker volume ls`. + if short not in self.volumes: + raise ManifestError(f"{self.name}: volume {short!r} not declared in manifest") + return f"furtka_{self.name}_{short}" + + +def load_manifest(path: Path) -> Manifest: + try: + raw = json.loads(path.read_text()) + except json.JSONDecodeError as e: + raise ManifestError(f"{path}: invalid JSON: {e}") from e + if not isinstance(raw, dict): + raise ManifestError(f"{path}: top-level must be an object") + + missing = [f for f in REQUIRED_FIELDS if f not in raw] + if missing: + raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}") + + name = raw["name"] + if not isinstance(name, str) or not name: + raise ManifestError(f"{path}: name must be a non-empty string") + expected = path.parent.name + if name != expected: + raise ManifestError(f"{path}: name {name!r} must equal containing folder {expected!r}") + + volumes = raw["volumes"] + if not isinstance(volumes, list) or not all(isinstance(v, str) and v for v in volumes): + raise ManifestError(f"{path}: volumes must be a list of non-empty strings") + + ports = raw["ports"] + if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports): + raise ManifestError(f"{path}: ports must be a list of integers") + + return Manifest( + name=name, + display_name=str(raw["display_name"]), + version=str(raw["version"]), + description=str(raw["description"]), + volumes=tuple(volumes), + ports=tuple(ports), + icon=str(raw["icon"]), + ) diff --git a/furtka/paths.py b/furtka/paths.py new file mode 100644 index 0000000..f32d895 --- /dev/null +++ b/furtka/paths.py @@ -0,0 +1,13 @@ +import os +from pathlib import Path + +DEFAULT_APPS_DIR = Path("/var/lib/furtka/apps") +DEFAULT_BUNDLED_APPS_DIR = Path("/opt/furtka/apps") + + +def apps_dir() -> Path: + return Path(os.environ.get("FURTKA_APPS_DIR", DEFAULT_APPS_DIR)) + + +def bundled_apps_dir() -> Path: + return Path(os.environ.get("FURTKA_BUNDLED_APPS_DIR", DEFAULT_BUNDLED_APPS_DIR)) diff --git a/furtka/scanner.py b/furtka/scanner.py new file mode 100644 index 0000000..74800fb --- /dev/null +++ b/furtka/scanner.py @@ -0,0 +1,35 @@ +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 diff --git a/pyproject.toml b/pyproject.toml index b91f52d..9948b54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,10 @@ select = [ [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["webinstaller"] +pythonpath = ["webinstaller", "."] + +[project.scripts] +furtka = "furtka.cli:main" [tool.setuptools] -py-modules = [] +packages = ["furtka"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..042d1fc --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,54 @@ +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 + assert "0 entries" in out diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..f587d7c --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,80 @@ +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) + + +def test_name_must_match_folder(tmp_path): + path = _write_app(tmp_path, "wrong-folder", VALID_MANIFEST) + with pytest.raises(ManifestError, match="must equal containing folder"): + load_manifest(path) + + +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) diff --git a/tests/test_scanner.py b/tests/test_scanner.py new file mode 100644 index 0000000..d4a9e1f --- /dev/null +++ b/tests/test_scanner.py @@ -0,0 +1,69 @@ +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"]