All checks were successful
Build ISO / build-iso (push) Successful in 18m3s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 1m22s
CI / validate-json (push) Successful in 25s
CI / markdown-links (push) Successful in 13s
Release / release (push) Successful in 12m13s
Whitespace-only — `ruff check` was green when 26.17-alpha shipped but I forgot to run `ruff format`, so the CI format-check job went red on the release commit. Runtime artifacts are unaffected (release.yml doesn't gate on lint); this just re-greens the main baseline going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
253 lines
9.4 KiB
Python
253 lines
9.4 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")]
|
|
|
|
|
|
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"])
|