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>
91 lines
2.9 KiB
Python
91 lines
2.9 KiB
Python
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")]
|