"""POST /deckies/deploy auto-mode: master + swarm hosts → shard to workers.""" from __future__ import annotations from unittest.mock import patch, AsyncMock import pytest from decnet.web.dependencies import repo from decnet.web.db.models import SwarmDeployResponse, SwarmHostResult @pytest.fixture(autouse=True) def contract_test_mode(monkeypatch): monkeypatch.setenv("DECNET_CONTRACT_TEST", "true") @pytest.fixture(autouse=True) def mock_network(): with patch("decnet.web.router.fleet.api_deploy_deckies.get_host_ip", return_value="192.168.1.100"): with patch("decnet.web.router.fleet.api_deploy_deckies.detect_interface", return_value="eth0"): with patch("decnet.web.router.fleet.api_deploy_deckies.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1")): yield @pytest.mark.anyio async def test_deploy_automode_unihost_when_no_swarm_hosts(client, auth_token, monkeypatch): """No swarm hosts enrolled → local unihost deploy.""" monkeypatch.setenv("DECNET_MODE", "master") for row in await repo.list_swarm_hosts(): await repo.delete_swarm_host(row["uuid"]) await repo.set_state("deployment", None) ini = "[decky-solo]\nservices = ssh\n" resp = await client.post( "/api/v1/deckies/deploy", json={"ini_content": ini}, headers={"Authorization": f"Bearer {auth_token}"}, ) assert resp.status_code == 200, resp.text assert resp.json()["mode"] == "unihost" @pytest.mark.anyio async def test_deploy_automode_shards_when_swarm_host_enrolled(client, auth_token, monkeypatch): """Master + one active swarm host → swarm mode, dispatch invoked.""" monkeypatch.setenv("DECNET_MODE", "master") await repo.set_state("deployment", None) for row in await repo.list_swarm_hosts(): await repo.delete_swarm_host(row["uuid"]) from datetime import datetime, timezone await repo.add_swarm_host({ "uuid": "host-A", "name": "worker-a", "address": "10.0.0.50", "agent_port": 8765, "status": "active", "client_cert_fingerprint": "x" * 64, "updater_cert_fingerprint": None, "cert_bundle_path": "/tmp/worker-a", "enrolled_at": datetime.now(timezone.utc), "notes": "", }) fake_response = SwarmDeployResponse(results=[ SwarmHostResult(host_uuid="host-A", host_name="worker-a", ok=True, detail={}) ]) with patch( "decnet.web.router.fleet.api_deploy_deckies.dispatch_decnet_config", new=AsyncMock(return_value=fake_response), ) as mock_dispatch: ini = "[decky-01]\nservices = ssh\n[decky-02]\nservices = http\n" resp = await client.post( "/api/v1/deckies/deploy", json={"ini_content": ini}, headers={"Authorization": f"Bearer {auth_token}"}, ) assert resp.status_code == 200, resp.text assert resp.json()["mode"] == "swarm" assert mock_dispatch.await_count == 1 dispatched_config = mock_dispatch.await_args.args[0] assert dispatched_config.mode == "swarm" assert all(d.host_uuid == "host-A" for d in dispatched_config.deckies) await repo.delete_swarm_host("host-A") @pytest.mark.anyio async def test_deploy_automode_resets_stale_host_uuid(client, auth_token, monkeypatch): """Deckies carried over from prior state must not be dispatched to a host uuid that no longer exists — reset + round-robin against live hosts.""" monkeypatch.setenv("DECNET_MODE", "master") for row in await repo.list_swarm_hosts(): await repo.delete_swarm_host(row["uuid"]) from datetime import datetime, timezone await repo.add_swarm_host({ "uuid": "host-LIVE", "name": "live", "address": "10.0.0.60", "agent_port": 8765, "status": "active", "client_cert_fingerprint": "a" * 64, "updater_cert_fingerprint": None, "cert_bundle_path": "/tmp/live", "enrolled_at": datetime.now(timezone.utc), "notes": "", }) # Prior state: decky-old is assigned to a now-decommissioned host. await repo.set_state("deployment", { "config": { "mode": "swarm", "interface": "eth0", "subnet": "192.168.1.0/24", "gateway": "192.168.1.1", "deckies": [{ "name": "decky-old", "ip": "192.168.1.50", "services": ["ssh"], "distro": "debian", "base_image": "debian:bookworm-slim", "hostname": "decky-old", "host_uuid": "ghost-uuid", }], }, "compose_path": "", }) fake_response = SwarmDeployResponse(results=[ SwarmHostResult(host_uuid="host-LIVE", host_name="live", ok=True, detail={}) ]) with patch( "decnet.web.router.fleet.api_deploy_deckies.dispatch_decnet_config", new=AsyncMock(return_value=fake_response), ) as mock_dispatch: ini = "[decky-new]\nservices = ssh\n" resp = await client.post( "/api/v1/deckies/deploy", json={"ini_content": ini}, headers={"Authorization": f"Bearer {auth_token}"}, ) assert resp.status_code == 200, resp.text dispatched = mock_dispatch.await_args.args[0] # Both the carried-over decky and the new one must point at the live host. assert {d.host_uuid for d in dispatched.deckies} == {"host-LIVE"} await repo.delete_swarm_host("host-LIVE") await repo.set_state("deployment", None) @pytest.mark.anyio async def test_deploy_automode_flips_ipvlan_for_opted_in_host(client, auth_token, monkeypatch): """A host enrolled with use_ipvlan=True must receive a DecnetConfig with ipvlan=True in its shard — sharding is per-host, so _worker_config flips it.""" from decnet.web.router.swarm.api_deploy_swarm import _worker_config from decnet.config import DecnetConfig, DeckyConfig base = DecnetConfig( mode="swarm", interface="eth0", subnet="192.168.1.0/24", gateway="192.168.1.1", ipvlan=False, deckies=[DeckyConfig( name="decky-1", ip="192.168.1.10", services=["ssh"], distro="debian", base_image="debian:bookworm-slim", hostname="decky-1", host_uuid="h1", )], ) opted_in = {"uuid": "h1", "name": "w1", "use_ipvlan": True} opted_out = {"uuid": "h1", "name": "w1", "use_ipvlan": False} assert _worker_config(base, base.deckies, opted_in).ipvlan is True assert _worker_config(base, base.deckies, opted_out).ipvlan is False @pytest.mark.anyio async def test_deployment_mode_endpoint(client, auth_token, monkeypatch): monkeypatch.setenv("DECNET_MODE", "master") for row in await repo.list_swarm_hosts(): await repo.delete_swarm_host(row["uuid"]) resp = await client.get( "/api/v1/system/deployment-mode", headers={"Authorization": f"Bearer {auth_token}"}, ) assert resp.status_code == 200 body = resp.json() assert body["role"] == "master" assert body["mode"] == "unihost" assert body["swarm_host_count"] == 0