furtka/tests/test_installer.py
Daniel Maksymilian Syrnicki 7b96a25f5b 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>
2026-04-15 10:02:00 +02:00

118 lines
3.7 KiB
Python

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