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")] def test_reconcile_isolates_per_app_docker_failure(tmp_path, monkeypatch): """A docker error on one app must not block reconcile of the others.""" _make_app(tmp_path, "alpha", dict(VALID_MANIFEST, name="alpha", volumes=["a"])) _make_app(tmp_path, "broken-app", dict(VALID_MANIFEST, name="broken-app", volumes=["b"])) _make_app(tmp_path, "zulu", dict(VALID_MANIFEST, name="zulu", volumes=["z"])) succeeded_compose: list[str] = [] def fake_ensure(name): return True def fake_compose_up(app_dir, project): if project == "broken-app": raise dockerops.DockerError("simulated daemon failure") succeeded_compose.append(project) monkeypatch.setattr(dockerops, "ensure_volume", fake_ensure) monkeypatch.setattr(dockerops, "compose_up", fake_compose_up) actions = reconciler.reconcile(tmp_path) error_actions = [a for a in actions if a.kind == "error"] assert reconciler.has_errors(actions) assert len(error_actions) == 1 assert error_actions[0].target == "broken-app" # Both healthy apps reconciled despite the broken one in the middle. assert succeeded_compose == ["alpha", "zulu"] def test_reconcile_isolates_missing_docker_binary(tmp_path, monkeypatch): """No docker binary on the box still produces a tidy per-app error.""" _make_app(tmp_path, "fileshare", VALID_MANIFEST) def boom(*args, **kwargs): raise FileNotFoundError("[Errno 2] No such file or directory: 'docker'") monkeypatch.setattr(dockerops, "ensure_volume", boom) actions = reconciler.reconcile(tmp_path) assert reconciler.has_errors(actions) error = next(a for a in actions if a.kind == "error") assert error.target == "fileshare" assert "docker" in error.detail # --- Topo ordering + on_start hooks ---------------------------------------- PROVIDER_MANIFEST = dict( VALID_MANIFEST, name="mosquitto", volumes=["data"], ) CONSUMER_MANIFEST = dict( VALID_MANIFEST, name="zigbee2mqtt", volumes=["state"], requires=[ { "app": "mosquitto", "on_start": "hooks/ensure-user.sh", } ], ) def test_reconcile_topo_orders_providers_before_consumers(tmp_path, fake_docker, monkeypatch): # Consumer comes alphabetically AFTER provider here, but the explicit dep # also needs to win when the order was reversed. Add an alpha-first # consumer name to make this load-bearing. consumer = dict(CONSUMER_MANIFEST, name="alpha", requires=[{"app": "mosquitto"}]) _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST) _make_app(tmp_path, "alpha", consumer) reconciler.reconcile(tmp_path) up_order = [project for _, project in fake_docker["compose_up"]] assert up_order == ["mosquitto", "alpha"] def test_reconcile_fires_on_start_before_compose_up(tmp_path, fake_docker, monkeypatch): provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST) (provider / "hooks").mkdir() (provider / "hooks" / "ensure-user.sh").write_bytes(b"#!/bin/sh\necho ok\n") _make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST) hook_calls: list[str] = [] def fake_exec_script(app_dir, project, service, script_path, *, env, timeout): hook_calls.append(f"{project}:{script_path.name}") return "" monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"}) monkeypatch.setattr(dockerops, "compose_exec_script", fake_exec_script) actions = reconciler.reconcile(tmp_path) # Hook fired against mosquitto exactly once. assert hook_calls == ["mosquitto:ensure-user.sh"] # Hook action appears before consumer's compose_up. kinds = [(a.kind, a.target) for a in actions] hook_idx = kinds.index(("hook", "zigbee2mqtt:mosquitto:on_start")) up_idx = kinds.index(("compose_up", "zigbee2mqtt")) assert hook_idx < up_idx # And the provider's compose_up happened first. assert fake_docker["compose_up"][0][1] == "mosquitto" def test_reconcile_on_start_failure_skips_consumer_compose_up(tmp_path, fake_docker, monkeypatch): provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST) (provider / "hooks").mkdir() (provider / "hooks" / "ensure-user.sh").write_bytes(b"") _make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST) # Unrelated third app: must still come up despite the consumer's hook fail. _make_app(tmp_path, "lonely", dict(VALID_MANIFEST, name="lonely", volumes=["data"])) def boom(*a, **k): raise dockerops.DockerError("hook returned 1") monkeypatch.setattr(dockerops, "compose_image_tags", lambda a, p: {"mosquitto": "img"}) monkeypatch.setattr(dockerops, "compose_exec_script", boom) actions = reconciler.reconcile(tmp_path) assert reconciler.has_errors(actions) error_actions = [a for a in actions if a.kind == "error"] assert len(error_actions) == 1 assert error_actions[0].target == "zigbee2mqtt" assert "on_start(mosquitto)" in error_actions[0].detail # Provider AND unrelated app came up; consumer did NOT. up_projects = {p for _, p in fake_docker["compose_up"]} assert "mosquitto" in up_projects assert "lonely" in up_projects assert "zigbee2mqtt" not in up_projects def test_reconcile_dry_run_emits_hook_action_without_executing(tmp_path, fake_docker, monkeypatch): provider = _make_app(tmp_path, "mosquitto", PROVIDER_MANIFEST) (provider / "hooks").mkdir() (provider / "hooks" / "ensure-user.sh").write_bytes(b"") _make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST) called = [] monkeypatch.setattr(dockerops, "compose_exec_script", lambda *a, **k: called.append(1) or "") actions = reconciler.reconcile(tmp_path, dry_run=True) assert called == [] hook_actions = [a for a in actions if a.kind == "hook"] assert any(a.target == "zigbee2mqtt:mosquitto:on_start" for a in hook_actions) def test_reconcile_missing_provider_still_isolated(tmp_path, fake_docker, monkeypatch): """Consumer requires an app that isn't installed — per-app error, others continue.""" _make_app(tmp_path, "zigbee2mqtt", CONSUMER_MANIFEST) _make_app(tmp_path, "lonely", dict(VALID_MANIFEST, name="lonely", volumes=["data"])) actions = reconciler.reconcile(tmp_path) assert reconciler.has_errors(actions) errors = [a for a in actions if a.kind == "error"] assert len(errors) == 1 assert errors[0].target == "zigbee2mqtt" # `lonely` still got its compose_up. assert any(p == "lonely" for _, p in fake_docker["compose_up"])