"""Unit coverage for engine.services_live add/remove flows. We don't shell out to docker — :func:`engine.deployer._compose` is patched to a no-op recorder. The DB (SQLite) and the topology hydrator run for real so the persistence path is exercised end-to-end. """ from __future__ import annotations import json from typing import Any, AsyncIterator import pytest import pytest_asyncio async def _get_topology_decky(repo, decky_uuid: str) -> dict[str, Any]: """Helper: list and pick the one matching uuid (no per-uuid getter on the repo).""" # Iterate all topologies' deckies — fine for tests with one row. topologies = await repo.list_topologies() for t in topologies: for d in await repo.list_topology_deckies(t.id): if d.uuid == decky_uuid: return d.model_dump() raise AssertionError(f"decky {decky_uuid!r} not found in any topology") from decnet.bus.fake import FakeBus from decnet.engine import services_live from decnet.engine.services_live import ServiceMutationError from decnet.web.db.sqlite.repository import SQLiteRepository import decnet.web.db.models # noqa: F401 — register tables @pytest_asyncio.fixture async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]: r = SQLiteRepository(str(tmp_path / "p.db")) await r.initialize() yield r @pytest_asyncio.fixture async def fake_bus(monkeypatch) -> AsyncIterator[FakeBus]: bus = FakeBus() await bus.connect() # services_live publishes via get_bus(); rebind to the fake. from decnet.bus import factory monkeypatch.setattr(factory, "get_bus", lambda: bus) yield bus await bus.close() @pytest_asyncio.fixture async def topology_with_decky(repo: SQLiteRepository) -> dict: """Persist one topology + one decky and return the IDs.""" topo_id = await repo.create_topology({ "name": "test-topo", "description": "", }) decky_uuid = await repo.add_topology_decky({ "topology_id": topo_id, "name": "web1", "ip": "10.0.0.5", "decky_config": {"name": "web1", "ips_by_lan": {}}, "services": ["http"], "state": "running", }) return {"topology_id": topo_id, "decky_uuid": decky_uuid} # ---------------- topology add -------------------------------------------- @pytest.mark.asyncio async def test_topology_add_service_persists_and_runs_compose_up( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, monkeypatch, tmp_path, ) -> None: captured: list[tuple[str, ...]] = [] def fake_compose(*args, compose_file=None, env=None): captured.append(args) monkeypatch.setattr(services_live, "_compose", fake_compose) # Avoid touching the real per-topology compose file path on disk. monkeypatch.setattr( services_live, "_topology_compose_path", lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml", ) sub = fake_bus.subscribe("decky.>") services = await services_live.add_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="web1", service_name="ssh", ) assert services == ["http", "ssh"] # Compose up was called targeting just the new service container. assert captured and captured[0][:5] == ( "up", "-d", "--no-deps", "--build", "web1-ssh", ) # Persisted to the DB. row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"]) persisted_services = json.loads(row["services"]) if isinstance(row["services"], str) else row["services"] assert "ssh" in persisted_services # Bus event published. import asyncio event = await asyncio.wait_for(sub.__anext__(), timeout=1.0) assert event.topic == "decky.web1.service_added" assert event.payload["service_name"] == "ssh" assert event.payload["topology_id"] == topology_with_decky["topology_id"] @pytest.mark.asyncio async def test_topology_add_service_rejects_unknown( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, ) -> None: with pytest.raises(ServiceMutationError, match="unknown service"): await services_live.add_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="web1", service_name="not-a-real-service", ) @pytest.mark.asyncio async def test_topology_add_service_rejects_duplicate( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, monkeypatch, tmp_path, ) -> None: monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: None) monkeypatch.setattr( services_live, "_topology_compose_path", lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml", ) with pytest.raises(ServiceMutationError, match="already on"): await services_live.add_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="web1", service_name="http", # already on ) @pytest.mark.asyncio async def test_topology_add_service_404_decky_not_in_topology( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, ) -> None: with pytest.raises(ServiceMutationError, match="not in topology"): await services_live.add_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="ghost", service_name="ssh", ) # ---------------- topology remove ----------------------------------------- @pytest.mark.asyncio async def test_topology_remove_service_runs_stop_then_rm( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, monkeypatch, tmp_path, ) -> None: captured: list[tuple[str, ...]] = [] monkeypatch.setattr( services_live, "_compose", lambda *a, **kw: captured.append(a), ) monkeypatch.setattr( services_live, "_topology_compose_path", lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml", ) services = await services_live.remove_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="web1", service_name="http", ) assert services == [] # Stop, then rm -f, in that order. assert captured[0] == ("stop", "web1-http") assert captured[1] == ("rm", "-f", "web1-http") @pytest.mark.asyncio async def test_topology_remove_service_rejects_when_absent( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, ) -> None: with pytest.raises(ServiceMutationError, match="not on"): await services_live.remove_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="web1", service_name="ssh", # not on ) # ---------------- topology add with initial config ------------------------ @pytest.mark.asyncio async def test_topology_add_service_with_initial_config_persists_to_decky_config( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, monkeypatch, tmp_path, ) -> None: monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: None) monkeypatch.setattr( services_live, "_topology_compose_path", lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml", ) await services_live.add_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="web1", service_name="ssh", config={"password": "hunter2", "hostname": "mail-01"}, ) row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"]) cfg_blob = json.loads(row["decky_config"]) if isinstance(row["decky_config"], str) else row["decky_config"] assert cfg_blob.get("service_config", {}).get("ssh") == { "password": "hunter2", "hostname": "mail-01", } @pytest.mark.asyncio async def test_topology_add_service_with_invalid_config_aborts_before_persist( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, monkeypatch, tmp_path, ) -> None: """Bad cfg → ConfigValidationError, no DB write, no compose call.""" from decnet.services.base import ConfigValidationError captured: list = [] monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: captured.append(a)) monkeypatch.setattr( services_live, "_topology_compose_path", lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml", ) with pytest.raises(ConfigValidationError): await services_live.add_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="web1", service_name="rdp", config={"nla": "not-a-bool"}, ) # Ensure no compose ran and the services list wasn't appended to. assert captured == [] row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"]) persisted = json.loads(row["services"]) if isinstance(row["services"], str) else row["services"] assert "rdp" not in persisted @pytest.mark.asyncio async def test_topology_add_service_empty_config_is_back_compat( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, monkeypatch, tmp_path, ) -> None: """No `config` arg / empty dict still adds the service — old callers safe.""" monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: None) monkeypatch.setattr( services_live, "_topology_compose_path", lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml", ) services = await services_live.add_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="web1", service_name="ssh", ) assert services == ["http", "ssh"] row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"]) cfg_blob = json.loads(row["decky_config"]) if isinstance(row["decky_config"], str) else row["decky_config"] # No service_config key written when config is empty. assert "ssh" not in (cfg_blob.get("service_config") or {}) @pytest.mark.asyncio async def test_topology_add_service_drops_unknown_config_keys( repo: SQLiteRepository, topology_with_decky: dict, fake_bus: FakeBus, monkeypatch, tmp_path, ) -> None: """validate_cfg drops unknown keys — they must not leak into decky_config.""" monkeypatch.setattr(services_live, "_compose", lambda *a, **kw: None) monkeypatch.setattr( services_live, "_topology_compose_path", lambda topo_id: tmp_path / f"compose-{topo_id[:8]}.yml", ) await services_live.add_service( repo, decky_kind="topology", topology_id=topology_with_decky["topology_id"], decky_name="web1", service_name="ssh", config={"password": "hunter2", "wat": "nope"}, ) row = await _get_topology_decky(repo, topology_with_decky["decky_uuid"]) cfg_blob = json.loads(row["decky_config"]) if isinstance(row["decky_config"], str) else row["decky_config"] assert cfg_blob["service_config"]["ssh"] == {"password": "hunter2"} # ---------------- service registry validation ----------------------------- def test_validate_rejects_fleet_singleton_services() -> None: """``fleet_singleton`` services run once fleet-wide, not per-decky.""" from decnet.services.registry import all_services singletons = [ name for name, svc in all_services().items() if svc.fleet_singleton ] if not singletons: pytest.skip("no fleet_singleton services registered") name = singletons[0] with pytest.raises(ServiceMutationError, match="fleet_singleton"): services_live._validate_service_for_per_decky(name) def test_validate_accepts_per_decky_service() -> None: svc = services_live._validate_service_for_per_decky("ssh") assert svc.name == "ssh"