feat(furtka): reconciler + install/remove — slice 2

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/<name>/ 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) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-15 10:02:00 +02:00
parent cfc4c0b9c1
commit 7b96a25f5b
7 changed files with 424 additions and 10 deletions

View file

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

51
furtka/dockerops.py Normal file
View file

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

74
furtka/installer.py Normal file
View file

@ -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 <source>` 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/<name>.
"""
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/<name>/.
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/<name>/. 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

35
furtka/reconciler.py Normal file
View file

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

View file

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

118
tests/test_installer.py Normal file
View file

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

91
tests/test_reconciler.py Normal file
View file

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