From 7b96a25f5bbdbd16f0ef1fb077fb6ab2f7e8f55d Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Wed, 15 Apr 2026 10:02:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(furtka):=20reconciler=20+=20install/remove?= =?UTF-8?q?=20=E2=80=94=20slice=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in the act-on-it half of the resource manager. Reconciler walks the scanner output and brings docker into the desired state: ensures each manifest-declared volume exists (idempotent), then runs docker compose up -d for the project. install/remove on the CLI work end-to-end against a real /var/lib/furtka/apps/ tree. - furtka.dockerops: thin subprocess wrapper. Volume + compose primitives that other modules call. `_run` raises DockerError with the actual stderr so failures are diagnosable. - furtka.reconciler: builds an ordered Action list (volumes then compose_up per app), executes unless dry-run. Broken manifests produce a "skip" action, the rest of the apps still get reconciled. - furtka.installer: copy-from-source with two non-obvious rules — user .env is preserved across upgrade installs, and a missing .env is bootstrapped from .env.example so compose has values to substitute on first install. Bundled-app lookup falls back to /opt/furtka/apps// when the source arg isn't a path. - furtka.cli: app install/remove wired up. remove() ignores compose down failures so a botched compose doesn't trap users with an un-removable folder. - 15 new tests using monkeypatch'd dockerops so the suite still runs without docker installed. Covers reconcile dry-run, multi-volume apps, broken-manifest skip behavior, .env preservation, bundled-name resolution, and remove edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- furtka/cli.py | 63 ++++++++++++++++++--- furtka/dockerops.py | 51 +++++++++++++++++ furtka/installer.py | 74 ++++++++++++++++++++++++ furtka/reconciler.py | 35 ++++++++++++ tests/test_cli.py | 2 +- tests/test_installer.py | 118 +++++++++++++++++++++++++++++++++++++++ tests/test_reconciler.py | 91 ++++++++++++++++++++++++++++++ 7 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 furtka/dockerops.py create mode 100644 furtka/installer.py create mode 100644 furtka/reconciler.py create mode 100644 tests/test_installer.py create mode 100644 tests/test_reconciler.py diff --git a/furtka/cli.py b/furtka/cli.py index e6637c4..c6b3466 100644 --- a/furtka/cli.py +++ b/furtka/cli.py @@ -2,6 +2,7 @@ import argparse import json import sys +from furtka import dockerops, installer, reconciler from furtka.paths import apps_dir from furtka.scanner import scan @@ -43,16 +44,46 @@ def _cmd_app_list(args: argparse.Namespace) -> int: return 0 +def _cmd_app_install(args: argparse.Namespace) -> int: + try: + src = installer.resolve_source(args.source) + target = installer.install_from(src) + except installer.InstallError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + print(f"installed {target.name} to {target}") + actions = reconciler.reconcile(apps_dir()) + for a in actions: + print(f" {a.describe()}") + return 0 + + +def _cmd_app_remove(args: argparse.Namespace) -> int: + target = apps_dir() / args.name + if not target.exists(): + print(f"error: {args.name!r} is not installed", file=sys.stderr) + return 1 + try: + dockerops.compose_down(target, args.name) + except dockerops.DockerError as e: + # Container may already be down (or never came up). Don't block removal. + print(f"warning: compose down failed, removing folder anyway: {e}", file=sys.stderr) + try: + installer.remove(args.name) + except installer.InstallError as e: + print(f"error: {e}", file=sys.stderr) + return 1 + print(f"removed {args.name} (volumes preserved)") + 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)") + actions = reconciler.reconcile(apps_dir(), dry_run=args.dry_run) + print(f"Scanned {apps_dir()}: {len(actions)} actions") + for a in actions: + print(f" {a.describe()}") + if args.dry_run: + print("(dry-run — nothing changed)") return 0 @@ -67,6 +98,20 @@ def build_parser() -> argparse.ArgumentParser: app_list.add_argument("--json", action="store_true", help="Emit JSON instead of a table") app_list.set_defaults(func=_cmd_app_list) + app_install = app_sub.add_parser( + "install", + help="Install an app from a local folder or a bundled-app name", + ) + app_install.add_argument( + "source", + help="Path to an app folder, or the name of a bundled app under /opt/furtka/apps/", + ) + app_install.set_defaults(func=_cmd_app_install) + + app_remove = app_sub.add_parser("remove", help="Stop and uninstall an app (keeps volumes)") + app_remove.add_argument("name", help="App name (folder name under /var/lib/furtka/apps/)") + app_remove.set_defaults(func=_cmd_app_remove) + reconcile = sub.add_parser( "reconcile", help="Bring docker state in line with /var/lib/furtka/apps", diff --git a/furtka/dockerops.py b/furtka/dockerops.py new file mode 100644 index 0000000..769abcb --- /dev/null +++ b/furtka/dockerops.py @@ -0,0 +1,51 @@ +import subprocess +from pathlib import Path + + +class DockerError(RuntimeError): + pass + + +def _run(args: list[str], cwd: Path | None = None, check: bool = True): + proc = subprocess.run( + args, + cwd=cwd, + check=False, + capture_output=True, + text=True, + ) + if check and proc.returncode != 0: + msg = proc.stderr.strip() or proc.stdout.strip() + raise DockerError(f"{' '.join(args)} exited {proc.returncode}: {msg}") + return proc + + +def volume_exists(name: str) -> bool: + return _run(["docker", "volume", "inspect", name], check=False).returncode == 0 + + +def ensure_volume(name: str) -> bool: + # Returns True if the volume was just created, False if it already existed. + if volume_exists(name): + return False + _run(["docker", "volume", "create", name]) + return True + + +def _compose_args(app_dir: Path, project: str) -> list[str]: + return [ + "docker", + "compose", + "--project-name", + project, + "--file", + str(app_dir / "docker-compose.yaml"), + ] + + +def compose_up(app_dir: Path, project: str) -> None: + _run([*_compose_args(app_dir, project), "up", "--detach"], cwd=app_dir) + + +def compose_down(app_dir: Path, project: str) -> None: + _run([*_compose_args(app_dir, project), "down"], cwd=app_dir) diff --git a/furtka/installer.py b/furtka/installer.py new file mode 100644 index 0000000..d32e95d --- /dev/null +++ b/furtka/installer.py @@ -0,0 +1,74 @@ +import shutil +from pathlib import Path + +from furtka.manifest import ManifestError, load_manifest +from furtka.paths import apps_dir, bundled_apps_dir + + +class InstallError(RuntimeError): + pass + + +def resolve_source(source: str) -> Path: + """Resolve a `furtka app install ` arg to a real source folder. + + If `source` looks like a path (or exists on disk), use it. Otherwise treat + it as a bundled app name and look up under /opt/furtka/apps/. + """ + p = Path(source) + if p.is_dir(): + return p + if "/" in source or source.startswith("."): + raise InstallError(f"{source!r} is not a directory") + bundled = bundled_apps_dir() / source + if bundled.is_dir(): + return bundled + raise InstallError(f"{source!r} not found as a path or bundled app") + + +def install_from(src: Path) -> Path: + """Copy a validated app folder into /var/lib/furtka/apps//. + + Preserves an existing .env on upgrade. Bootstraps .env from .env.example + on first install if .env wasn't shipped. + Returns the target folder. + """ + manifest_path = src / "manifest.json" + if not manifest_path.exists(): + raise InstallError(f"{src} has no manifest.json") + try: + m = load_manifest(manifest_path) + except ManifestError as e: + raise InstallError(str(e)) from e + + target = apps_dir() / m.name + target.mkdir(parents=True, exist_ok=True) + + for item in src.iterdir(): + if not item.is_file(): + continue + # Never overwrite an existing user .env. + if item.name == ".env" and (target / ".env").exists(): + continue + shutil.copy2(item, target / item.name) + + # First install with no .env shipped: bootstrap from .env.example so the + # user has something to edit and compose has values to substitute. + env = target / ".env" + env_example = target / ".env.example" + if not env.exists() and env_example.exists(): + shutil.copy2(env_example, env) + + return target + + +def remove(name: str) -> Path: + """Delete /var/lib/furtka/apps//. Volumes are NOT touched. + + Caller is responsible for stopping the compose project first. + """ + target = apps_dir() / name + if not target.exists(): + raise InstallError(f"{name!r} is not installed") + shutil.rmtree(target) + return target diff --git a/furtka/reconciler.py b/furtka/reconciler.py new file mode 100644 index 0000000..9dadaa3 --- /dev/null +++ b/furtka/reconciler.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from pathlib import Path + +from furtka import dockerops +from furtka.scanner import scan + + +@dataclass(frozen=True) +class Action: + kind: str # "ensure_volume" | "compose_up" | "skip" + target: str + detail: str = "" + + def describe(self) -> str: + if self.detail: + return f"{self.kind:14s} {self.target} ({self.detail})" + return f"{self.kind:14s} {self.target}" + + +def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]: + actions: list[Action] = [] + for result in scan(apps_root): + if not result.ok: + actions.append(Action("skip", result.path.name, result.error or "")) + continue + m = result.manifest + for vol_short in m.volumes: + full = m.volume_name(vol_short) + actions.append(Action("ensure_volume", full)) + if not dry_run: + dockerops.ensure_volume(full) + actions.append(Action("compose_up", m.name)) + if not dry_run: + dockerops.compose_up(result.path, m.name) + return actions diff --git a/tests/test_cli.py b/tests/test_cli.py index 042d1fc..24f8c2a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -51,4 +51,4 @@ def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys): rc = main(["reconcile", "--dry-run"]) assert rc == 0 out = capsys.readouterr().out - assert "0 entries" in out + assert "0 actions" in out diff --git a/tests/test_installer.py b/tests/test_installer.py new file mode 100644 index 0000000..65e01ec --- /dev/null +++ b/tests/test_installer.py @@ -0,0 +1,118 @@ +import json + +import pytest + +from furtka import installer +from furtka.paths import apps_dir, bundled_apps_dir + +VALID_MANIFEST = { + "name": "fileshare", + "display_name": "Network Files", + "version": "0.1.0", + "description": "SMB share", + "volumes": ["files"], + "ports": [445], + "icon": "icon.svg", +} + + +@pytest.fixture +def fake_dirs(tmp_path, monkeypatch): + apps = tmp_path / "apps" + bundled = tmp_path / "bundled" + apps.mkdir() + bundled.mkdir() + monkeypatch.setenv("FURTKA_APPS_DIR", str(apps)) + monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled)) + return apps, bundled + + +def _write_app_source(root, name, manifest, env_example=None, env=None): + app = root / name + app.mkdir() + (app / "manifest.json").write_text(json.dumps(manifest)) + (app / "docker-compose.yaml").write_text("services: {}\n") + if env_example is not None: + (app / ".env.example").write_text(env_example) + if env is not None: + (app / ".env").write_text(env) + return app + + +def test_resolve_source_explicit_path(tmp_path, fake_dirs): + src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST) + resolved = installer.resolve_source(str(src)) + assert resolved == src + + +def test_resolve_source_bundled_name(fake_dirs): + _, bundled = fake_dirs + src = _write_app_source(bundled, "fileshare", VALID_MANIFEST) + resolved = installer.resolve_source("fileshare") + assert resolved == src + + +def test_resolve_source_unknown_name(fake_dirs): + with pytest.raises(installer.InstallError, match="not found"): + installer.resolve_source("nope") + + +def test_resolve_source_path_with_slash_must_exist(fake_dirs): + with pytest.raises(installer.InstallError, match="not a directory"): + installer.resolve_source("./does-not-exist") + + +def test_install_from_copies_files(tmp_path, fake_dirs): + src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=1") + target = installer.install_from(src) + assert target == apps_dir() / "fileshare" + assert (target / "manifest.json").exists() + assert (target / "docker-compose.yaml").exists() + assert (target / ".env.example").exists() + # .env bootstrapped from .env.example since none was shipped + assert (target / ".env").read_text() == "A=1" + + +def test_install_from_preserves_existing_env(tmp_path, fake_dirs): + src = _write_app_source(tmp_path, "fileshare", VALID_MANIFEST, env_example="A=new") + target = apps_dir() / "fileshare" + target.mkdir() + (target / ".env").write_text("A=user-edited") + installer.install_from(src) + # User .env not clobbered. + assert (target / ".env").read_text() == "A=user-edited" + # But .env.example was updated. + assert (target / ".env.example").read_text() == "A=new" + + +def test_install_from_rejects_missing_manifest(tmp_path, fake_dirs): + src = tmp_path / "broken" + src.mkdir() + with pytest.raises(installer.InstallError, match="manifest.json"): + installer.install_from(src) + + +def test_install_from_rejects_invalid_manifest(tmp_path, fake_dirs): + bad = dict(VALID_MANIFEST) + del bad["volumes"] + src = _write_app_source(tmp_path, "fileshare", bad) + with pytest.raises(installer.InstallError, match="volumes"): + installer.install_from(src) + + +def test_remove_deletes_folder(fake_dirs): + apps, _ = fake_dirs + (apps / "fileshare").mkdir() + (apps / "fileshare" / "manifest.json").write_text("{}") + installer.remove("fileshare") + assert not (apps / "fileshare").exists() + + +def test_remove_unknown_raises(fake_dirs): + with pytest.raises(installer.InstallError, match="not installed"): + installer.remove("ghost") + + +def test_bundled_apps_dir_uses_env_override(fake_dirs): + _, bundled = fake_dirs + assert bundled_apps_dir() == bundled diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py new file mode 100644 index 0000000..e102d39 --- /dev/null +++ b/tests/test_reconciler.py @@ -0,0 +1,91 @@ +import json + +import pytest + +from furtka import dockerops, reconciler + +VALID_MANIFEST = { + "name": "fileshare", + "display_name": "Network Files", + "version": "0.1.0", + "description": "SMB share", + "volumes": ["files", "config"], + "ports": [445], + "icon": "icon.svg", +} + + +@pytest.fixture +def fake_docker(monkeypatch): + """Replace dockerops with no-op recorders so reconcile() doesn't shell out.""" + calls: dict[str, list] = {"ensure_volume": [], "compose_up": [], "compose_down": []} + existing_volumes: set[str] = set() + + def fake_ensure(name): + calls["ensure_volume"].append(name) + if name in existing_volumes: + return False + existing_volumes.add(name) + return True + + def fake_compose_up(app_dir, project): + calls["compose_up"].append((str(app_dir), project)) + + def fake_compose_down(app_dir, project): + calls["compose_down"].append((str(app_dir), project)) + + monkeypatch.setattr(dockerops, "ensure_volume", fake_ensure) + monkeypatch.setattr(dockerops, "compose_up", fake_compose_up) + monkeypatch.setattr(dockerops, "compose_down", fake_compose_down) + return calls + + +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)) + (app / "docker-compose.yaml").write_text("services: {}\n") + return app + + +def test_reconcile_empty_root(tmp_path, fake_docker): + actions = reconciler.reconcile(tmp_path) + assert actions == [] + assert fake_docker["ensure_volume"] == [] + assert fake_docker["compose_up"] == [] + + +def test_reconcile_one_app(tmp_path, fake_docker): + _make_app(tmp_path, "fileshare", VALID_MANIFEST) + actions = reconciler.reconcile(tmp_path) + assert [(a.kind, a.target) for a in actions] == [ + ("ensure_volume", "furtka_fileshare_files"), + ("ensure_volume", "furtka_fileshare_config"), + ("compose_up", "fileshare"), + ] + assert fake_docker["ensure_volume"] == [ + "furtka_fileshare_files", + "furtka_fileshare_config", + ] + assert len(fake_docker["compose_up"]) == 1 + assert fake_docker["compose_up"][0][1] == "fileshare" + + +def test_reconcile_dry_run_does_not_act(tmp_path, fake_docker): + _make_app(tmp_path, "fileshare", VALID_MANIFEST) + actions = reconciler.reconcile(tmp_path, dry_run=True) + assert len(actions) == 3 + assert fake_docker["ensure_volume"] == [] + assert fake_docker["compose_up"] == [] + + +def test_reconcile_skips_broken_manifest(tmp_path, fake_docker): + _make_app(tmp_path, "fileshare", VALID_MANIFEST) + _make_app(tmp_path, "broken") # no manifest + actions = reconciler.reconcile(tmp_path) + skip_actions = [a for a in actions if a.kind == "skip"] + assert len(skip_actions) == 1 + assert skip_actions[0].target == "broken" + # Healthy app still got reconciled. + assert fake_docker["compose_up"] == [(str(tmp_path / "fileshare"), "fileshare")]