The web deploy collision-guard read the existing fleet from the DB State["deployment"] key, while the UI/get_deckies() read decnet-state.json. A fleet established via CLI/seed lands in neither path the guard consulted, so existing_deckies was empty, the additive guard ran blind, and the reconciler tore the running fleet down to the single submitted decky (BUG-2: silent fleet wipe, HTTP 202, no warning). Converge both reads on fleet_deckies — the engine-mirrored table written on every deploy/teardown (CLI and web), which fleet/reconciler.py already documents as the store the orchestrator, dashboard, and REST API see. Each row's decky_config column is a full DeckyConfig dump, so it rehydrates losslessly into the collision-guard input. The handler also commits the intended fleet to fleet_deckies synchronously so rapid sequential deploys read a current fleet and the dashboard observes the new shape immediately. State["deployment"] is retained for now — the mutate handlers and the mutator engine still coordinate through it; consolidating them is tracked in development/ADR-001-FLEET-SOURCE-OF-TRUTH.md (open question 7). Tests seed fleet_deckies directly (also modelling the CLI-seeded scenario) rather than chaining real deploys through the skipped contract-test path.
111 lines
3.8 KiB
Python
111 lines
3.8 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
from decnet.config import DeckyConfig
|
|
from decnet.web.db.models import LOCAL_HOST_SENTINEL
|
|
from decnet.web.dependencies import repo
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def contract_test_mode(monkeypatch):
|
|
"""Skip actual Docker deployment in tests."""
|
|
monkeypatch.setenv("DECNET_CONTRACT_TEST", "true")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_network():
|
|
"""Mock network detection so deploy doesn't call `ip addr show`."""
|
|
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
|
|
|
|
|
|
async def _clear_fleet() -> None:
|
|
for row in await repo.list_fleet_deckies():
|
|
await repo.delete_fleet_decky(
|
|
host_uuid=row.get("host_uuid") or LOCAL_HOST_SENTINEL,
|
|
name=row["name"],
|
|
)
|
|
|
|
|
|
async def _seed_fleet(name: str, ip: str) -> None:
|
|
cfg = DeckyConfig(
|
|
name=name, ip=ip, services=["ssh"], distro="debian",
|
|
base_image="debian", hostname=name,
|
|
)
|
|
await repo.upsert_fleet_decky({
|
|
"host_uuid": LOCAL_HOST_SENTINEL,
|
|
"name": name,
|
|
"services": ["ssh"],
|
|
"decky_config": cfg.model_dump(mode="json"),
|
|
"decky_ip": ip,
|
|
"state": "running",
|
|
})
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def _isolate_fleet():
|
|
await _clear_fleet()
|
|
yield
|
|
await _clear_fleet()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_deploy_respects_limit(client, auth_token):
|
|
"""The limit counts the WHOLE resulting fleet — existing (from
|
|
fleet_deckies) plus the submitted INI — not the INI alone. One existing
|
|
decky + one submitted, against a limit of 1, must be rejected."""
|
|
await repo.set_state("config_limits", {"deployment_limit": 1})
|
|
await _seed_fleet("decky-existing", "192.168.1.10")
|
|
|
|
ini = "[decky-new]\nservices = ssh\n"
|
|
resp = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={"ini_content": ini},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
# existing(1) + new(1) = 2 > limit 1
|
|
assert resp.status_code == 409
|
|
assert "limit" in resp.json()["detail"].lower()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_deploy_replaces_prior_state(client, auth_token):
|
|
"""replace_fleet=True drops the prior fleet rather than silently
|
|
re-including it (the 'Address already in use' regression came from stale
|
|
deckies redeploying on stale IPs). After replace, the committed fleet is
|
|
exactly the submitted INI."""
|
|
await repo.set_state("config_limits", {"deployment_limit": 10})
|
|
await _seed_fleet("test-decky-1", "192.168.1.10")
|
|
await _seed_fleet("test-decky-2", "192.168.1.11")
|
|
|
|
ini = "[only-decky]\nservices = ssh\n"
|
|
resp = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={"ini_content": ini, "replace_fleet": True},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
assert resp.status_code == 202, resp.text
|
|
names = {d["name"] for d in await repo.get_deckies()}
|
|
assert names == {"only-decky"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_deploy_within_limit(client, auth_token):
|
|
"""Deploy should succeed when the resulting fleet is within limit."""
|
|
await repo.set_state("config_limits", {"deployment_limit": 100})
|
|
await _seed_fleet("decky-existing", "192.168.1.10")
|
|
|
|
ini = "[decky-new]\nservices = ssh\n"
|
|
resp = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={"ini_content": ini},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
if resp.status_code == 409:
|
|
assert "limit" not in resp.json()["detail"].lower()
|
|
else:
|
|
assert resp.status_code == 202
|