310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""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"
|