Files
DECNET/tests/swarm/test_swarm_api.py
anti 63b0a58527 feat(swarm): master-side SWARM controller (swarmctl) + agent CLI
Adds decnet/web/swarm_api.py as an independent FastAPI app with routers
for host enrollment, deployment dispatch (sharding DecnetConfig across
enrolled workers via AgentClient), and active health probing. Runs as
its own uvicorn subprocess via 'decnet swarmctl', mirroring the isolation
pattern used by 'decnet api'. Also wires up 'decnet agent' CLI entry for
the worker side.

29 tests added under tests/swarm/test_swarm_api.py cover enrollment
(including bundle generation + duplicate rejection), host CRUD, sharding
correctness, non-swarm-mode rejection, teardown, and health probes with
a stubbed AgentClient.
2026-04-18 19:18:33 -04:00

295 lines
9.4 KiB
Python

"""Unit tests for the SWARM controller FastAPI app.
Covers the enrollment, host-management, and deployment dispatch routes.
The AgentClient is stubbed so we exercise the controller's logic without
a live mTLS peer (that path has its own roundtrip test).
"""
from __future__ import annotations
import pathlib
from typing import Any
import pytest
from fastapi.testclient import TestClient
from decnet.web.db.factory import get_repository
from decnet.web.dependencies import get_repo
@pytest.fixture
def ca_dir(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
"""Redirect the PKI default CA path into tmp so the test CA never
touches ``~/.decnet/ca``."""
ca = tmp_path / "ca"
from decnet.swarm import pki
monkeypatch.setattr(pki, "DEFAULT_CA_DIR", ca)
# Also patch the already-imported references inside client.py / routers.
from decnet.swarm import client as swarm_client
from decnet.web.router.swarm import hosts as swarm_hosts
monkeypatch.setattr(swarm_client, "pki", pki)
monkeypatch.setattr(swarm_hosts, "pki", pki)
return ca
@pytest.fixture
def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch):
r = get_repository(db_path=str(tmp_path / "swarm.db"))
# The controller's lifespan initialises the module-level `repo` in
# decnet.web.dependencies. Swap that singleton for our test repo so
# schema creation targets the temp DB.
import decnet.web.dependencies as deps
import decnet.web.swarm_api as swarm_api_mod
monkeypatch.setattr(deps, "repo", r)
monkeypatch.setattr(swarm_api_mod, "repo", r)
return r
@pytest.fixture
def client(repo, ca_dir: pathlib.Path):
from decnet.web.swarm_api import app
async def _override() -> Any:
return repo
app.dependency_overrides[get_repo] = _override
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
# ---------------------------------------------------------------- /enroll
def test_enroll_creates_host_and_returns_bundle(client: TestClient) -> None:
resp = client.post(
"/swarm/enroll",
json={"name": "worker-a", "address": "10.0.0.5", "agent_port": 8765},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["name"] == "worker-a"
assert body["address"] == "10.0.0.5"
assert "-----BEGIN CERTIFICATE-----" in body["worker_cert_pem"]
assert "-----BEGIN PRIVATE KEY-----" in body["worker_key_pem"]
assert "-----BEGIN CERTIFICATE-----" in body["ca_cert_pem"]
assert len(body["fingerprint"]) == 64 # sha256 hex
def test_enroll_rejects_duplicate_name(client: TestClient) -> None:
payload = {"name": "worker-dup", "address": "10.0.0.6", "agent_port": 8765}
assert client.post("/swarm/enroll", json=payload).status_code == 201
resp2 = client.post("/swarm/enroll", json=payload)
assert resp2.status_code == 409
# ---------------------------------------------------------------- /hosts
def test_list_hosts_empty(client: TestClient) -> None:
resp = client.get("/swarm/hosts")
assert resp.status_code == 200
assert resp.json() == []
def test_list_and_get_host_after_enroll(client: TestClient) -> None:
reg = client.post(
"/swarm/enroll",
json={"name": "worker-b", "address": "10.0.0.7", "agent_port": 8765},
).json()
uuid = reg["host_uuid"]
lst = client.get("/swarm/hosts").json()
assert len(lst) == 1
assert lst[0]["name"] == "worker-b"
one = client.get(f"/swarm/hosts/{uuid}").json()
assert one["uuid"] == uuid
assert one["status"] == "enrolled"
def test_decommission_removes_host_and_bundle(
client: TestClient, ca_dir: pathlib.Path
) -> None:
reg = client.post(
"/swarm/enroll",
json={"name": "worker-c", "address": "10.0.0.8", "agent_port": 8765},
).json()
uuid = reg["host_uuid"]
bundle_dir = ca_dir / "workers" / "worker-c"
assert bundle_dir.is_dir()
resp = client.delete(f"/swarm/hosts/{uuid}")
assert resp.status_code == 204
assert client.get(f"/swarm/hosts/{uuid}").status_code == 404
assert not bundle_dir.exists()
# ---------------------------------------------------------------- /deploy
class _StubAgentClient:
"""Minimal async-context-manager stub mirroring ``AgentClient``."""
deployed: list[dict[str, Any]] = []
torn_down: list[dict[str, Any]] = []
def __init__(self, host: dict[str, Any] | None = None, **_: Any) -> None:
self._host = host or {}
async def __aenter__(self) -> "_StubAgentClient":
return self
async def __aexit__(self, *exc: Any) -> None:
return None
async def health(self) -> dict[str, Any]:
return {"status": "ok"}
async def deploy(self, config: Any, **kw: Any) -> dict[str, Any]:
_StubAgentClient.deployed.append(
{"host": self._host.get("name"), "deckies": [d.name for d in config.deckies]}
)
return {"status": "deployed", "deckies": len(config.deckies)}
async def teardown(self, decky_id: str | None = None) -> dict[str, Any]:
_StubAgentClient.torn_down.append(
{"host": self._host.get("name"), "decky_id": decky_id}
)
return {"status": "torn_down"}
@pytest.fixture
def stub_agent(monkeypatch: pytest.MonkeyPatch):
_StubAgentClient.deployed.clear()
_StubAgentClient.torn_down.clear()
from decnet.web.router.swarm import deployments as dep_mod
from decnet.web.router.swarm import health as hlt_mod
monkeypatch.setattr(dep_mod, "AgentClient", _StubAgentClient)
monkeypatch.setattr(hlt_mod, "AgentClient", _StubAgentClient)
return _StubAgentClient
def _decky_dict(name: str, host_uuid: str, ip: str) -> dict[str, Any]:
return {
"name": name,
"ip": ip,
"services": ["ssh"],
"distro": "debian",
"base_image": "debian:bookworm-slim",
"hostname": name,
"host_uuid": host_uuid,
}
def test_deploy_shards_across_hosts(client: TestClient, stub_agent) -> None:
h1 = client.post(
"/swarm/enroll",
json={"name": "w1", "address": "10.0.0.1", "agent_port": 8765},
).json()
h2 = client.post(
"/swarm/enroll",
json={"name": "w2", "address": "10.0.0.2", "agent_port": 8765},
).json()
cfg = {
"mode": "swarm",
"interface": "eth0",
"subnet": "192.168.1.0/24",
"gateway": "192.168.1.1",
"deckies": [
_decky_dict("decky-01", h1["host_uuid"], "192.168.1.10"),
_decky_dict("decky-02", h1["host_uuid"], "192.168.1.11"),
_decky_dict("decky-03", h2["host_uuid"], "192.168.1.12"),
],
}
resp = client.post("/swarm/deploy", json={"config": cfg})
assert resp.status_code == 200, resp.text
body = resp.json()
assert len(body["results"]) == 2
assert all(r["ok"] for r in body["results"])
by_host = {d["host"]: d["deckies"] for d in stub_agent.deployed}
assert by_host["w1"] == ["decky-01", "decky-02"]
assert by_host["w2"] == ["decky-03"]
def test_deploy_rejects_missing_host_uuid(client: TestClient, stub_agent) -> None:
cfg = {
"mode": "swarm",
"interface": "eth0",
"subnet": "192.168.1.0/24",
"gateway": "192.168.1.1",
"deckies": [
{
"name": "decky-01",
"ip": "192.168.1.10",
"services": ["ssh"],
"distro": "debian",
"base_image": "debian:bookworm-slim",
"hostname": "decky-01",
# host_uuid deliberately omitted
}
],
}
resp = client.post("/swarm/deploy", json={"config": cfg})
assert resp.status_code == 400
assert "host_uuid" in resp.json()["detail"]
def test_deploy_rejects_non_swarm_mode(client: TestClient, stub_agent) -> None:
cfg = {
"mode": "unihost",
"interface": "eth0",
"subnet": "192.168.1.0/24",
"gateway": "192.168.1.1",
"deckies": [_decky_dict("decky-01", "fake-uuid", "192.168.1.10")],
}
resp = client.post("/swarm/deploy", json={"config": cfg})
assert resp.status_code == 400
def test_teardown_all_hosts(client: TestClient, stub_agent) -> None:
for i, addr in enumerate(("10.0.0.1", "10.0.0.2"), start=1):
client.post(
"/swarm/enroll",
json={"name": f"td{i}", "address": addr, "agent_port": 8765},
)
resp = client.post("/swarm/teardown", json={})
assert resp.status_code == 200
assert len(resp.json()["results"]) == 2
assert {t["host"] for t in stub_agent.torn_down} == {"td1", "td2"}
# ---------------------------------------------------------------- /check
def test_check_marks_hosts_active(client: TestClient, stub_agent) -> None:
h = client.post(
"/swarm/enroll",
json={"name": "probe-w", "address": "10.0.0.9", "agent_port": 8765},
).json()
resp = client.post("/swarm/check")
assert resp.status_code == 200
results = resp.json()["results"]
assert len(results) == 1
assert results[0]["reachable"] is True
one = client.get(f"/swarm/hosts/{h['host_uuid']}").json()
assert one["status"] == "active"
assert one["last_heartbeat"] is not None
# ---------------------------------------------------------------- /health (root)
def test_root_health(client: TestClient) -> None:
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json()["role"] == "swarm-controller"