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>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-15 09:59:41 +02:00
parent 28e82bfccb
commit cfc4c0b9c1
10 changed files with 545 additions and 2 deletions

126
docs/resource-manager.md Normal file
View file

@ -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/<app-name>/`. 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 <tarball>` / `<git repo>` / `<name-from-catalog>`?
- 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 Q1Q7 are answered — adding them to an existing model is straightforward; guessing them now is expensive.

0
furtka/__init__.py Normal file
View file

90
furtka/cli.py Normal file
View file

@ -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())

73
furtka/manifest.py Normal file
View file

@ -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"]),
)

13
furtka/paths.py Normal file
View file

@ -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))

35
furtka/scanner.py Normal file
View file

@ -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

View file

@ -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"]

54
tests/test_cli.py Normal file
View file

@ -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

80
tests/test_manifest.py Normal file
View file

@ -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)

69
tests/test_scanner.py Normal file
View file

@ -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"]