"""POST /swarm/hosts/{uuid}/teardown — per-host and per-decky remote teardown.""" from __future__ import annotations import json from datetime import datetime, timezone from typing import Optional import pytest from decnet.web.router.swarm_mgmt import api_teardown_host as mod class _FakeAgent: def __init__(self, *a, **kw): _FakeAgent.calls.append(("init", kw.get("host", a[0] if a else None))) self._host = kw.get("host", a[0] if a else None) async def __aenter__(self): return self async def __aexit__(self, *exc): return None async def teardown(self, decky_id: Optional[str] = None) -> dict: _FakeAgent.calls.append(("teardown", decky_id)) return {"status": "torn_down", "decky_id": decky_id} class _FailingAgent(_FakeAgent): async def teardown(self, decky_id: Optional[str] = None) -> dict: raise RuntimeError("network unreachable") @pytest.fixture def fake_agent(monkeypatch): _FakeAgent.calls = [] monkeypatch.setattr(mod, "AgentClient", _FakeAgent) return _FakeAgent @pytest.fixture def failing_agent(monkeypatch): _FailingAgent.calls = [] monkeypatch.setattr(mod, "AgentClient", _FailingAgent) return _FailingAgent async def _seed_host(repo, *, name="worker-a", uuid="h-1") -> str: await repo.add_swarm_host({ "uuid": uuid, "name": name, "address": "10.0.0.9", "agent_port": 8765, "status": "active", "client_cert_fingerprint": "f" * 64, "cert_bundle_path": "", "use_ipvlan": False, "enrolled_at": datetime.now(timezone.utc), "last_heartbeat": None, }) return uuid async def _seed_shard(repo, *, host_uuid: str, decky_name: str) -> None: await repo.upsert_decky_shard({ "decky_name": decky_name, "host_uuid": host_uuid, "services": json.dumps(["ssh"]), "state": "running", "last_error": None, "updated_at": datetime.now(timezone.utc), }) @pytest.mark.anyio async def test_teardown_all_deckies_on_host(client, auth_token, fake_agent): from decnet.web.dependencies import repo uuid = await _seed_host(repo, name="tear-all", uuid="tear-all-uuid") await _seed_shard(repo, host_uuid=uuid, decky_name="decky1") await _seed_shard(repo, host_uuid=uuid, decky_name="decky2") resp = await client.post( f"/api/v1/swarm/hosts/{uuid}/teardown", headers={"Authorization": f"Bearer {auth_token}"}, json={}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["ok"] is True assert body["decky_id"] is None assert ("teardown", None) in fake_agent.calls remaining = await repo.list_decky_shards(uuid) assert remaining == [] @pytest.mark.anyio async def test_teardown_single_decky(client, auth_token, fake_agent): from decnet.web.dependencies import repo uuid = await _seed_host(repo, name="tear-one", uuid="tear-one-uuid") await _seed_shard(repo, host_uuid=uuid, decky_name="decky-keep") await _seed_shard(repo, host_uuid=uuid, decky_name="decky-drop") resp = await client.post( f"/api/v1/swarm/hosts/{uuid}/teardown", headers={"Authorization": f"Bearer {auth_token}"}, json={"decky_id": "decky-drop"}, ) assert resp.status_code == 200, resp.text body = resp.json() assert body["decky_id"] == "decky-drop" assert ("teardown", "decky-drop") in fake_agent.calls remaining = {s["decky_name"] for s in await repo.list_decky_shards(uuid)} assert remaining == {"decky-keep"} @pytest.mark.anyio async def test_teardown_unknown_host_404(client, auth_token, fake_agent): resp = await client.post( "/api/v1/swarm/hosts/does-not-exist/teardown", headers={"Authorization": f"Bearer {auth_token}"}, json={}, ) assert resp.status_code == 404 @pytest.mark.anyio async def test_teardown_agent_failure_502(client, auth_token, failing_agent): """When the worker is unreachable the DB shards MUST NOT be deleted — otherwise the master's view diverges from reality.""" from decnet.web.dependencies import repo uuid = await _seed_host(repo, name="tear-fail", uuid="tear-fail-uuid") await _seed_shard(repo, host_uuid=uuid, decky_name="survivor") resp = await client.post( f"/api/v1/swarm/hosts/{uuid}/teardown", headers={"Authorization": f"Bearer {auth_token}"}, json={}, ) assert resp.status_code == 502 remaining = {s["decky_name"] for s in await repo.list_decky_shards(uuid)} assert remaining == {"survivor"} @pytest.mark.anyio async def test_teardown_non_admin_forbidden(client, viewer_token, fake_agent): from decnet.web.dependencies import repo uuid = await _seed_host(repo, name="tear-guard", uuid="tear-guard-uuid") resp = await client.post( f"/api/v1/swarm/hosts/{uuid}/teardown", headers={"Authorization": f"Bearer {viewer_token}"}, json={}, ) assert resp.status_code == 403 @pytest.mark.anyio async def test_teardown_no_auth_401(client, fake_agent): resp = await client.post( "/api/v1/swarm/hosts/whatever/teardown", json={}, ) assert resp.status_code == 401