fix(fleet): read existing fleet from fleet_deckies, not State["deployment"] (BUG-2)

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.
This commit is contained in:
2026-06-12 23:52:20 -04:00
parent 408810b3e2
commit ab1151ee7f
7 changed files with 415 additions and 105 deletions

View File

@@ -5,6 +5,13 @@ Default behaviour (replace_fleet=False) appends the INI to the existing
fleet so the wizard's "deploy one more decky" submit no longer wipes
prior deckies. replace_fleet=True preserves the historical
set-desired-state semantics for CLI / declarative callers.
The existing fleet is read from fleet_deckies — the engine-mirrored table
written on every deploy/teardown (CLI or web), per the source-of-truth
model in fleet/reconciler.py. These tests seed fleet_deckies directly,
which also models the BUG-2 scenario: a fleet established out of band
(CLI/seed) that the web deploy guard must see and append to rather than
wipe. See development/ADR-001-FLEET-SOURCE-OF-TRUTH.md.
"""
from __future__ import annotations
@@ -12,6 +19,8 @@ from unittest.mock import patch
import pytest
from decnet.config import DeckyConfig
from decnet.web.db.models import LOCAL_HOST_SENTINEL
from decnet.web.dependencies import repo
@@ -28,96 +37,111 @@ def mock_network():
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 = "192.168.1.10", services=("ssh",)) -> None:
"""Insert a decky into fleet_deckies, as the engine mirror does on a
CLI/web deploy. Stamps a full DeckyConfig into decky_config so the deploy
guard can rehydrate it."""
cfg = DeckyConfig(
name=name,
ip=ip,
services=list(services),
distro="debian",
base_image="debian:bookworm-slim",
hostname=name,
)
await repo.upsert_fleet_decky({
"host_uuid": LOCAL_HOST_SENTINEL,
"name": name,
"services": list(services),
"decky_config": cfg.model_dump(mode="json"),
"decky_ip": ip,
"state": "running",
})
@pytest.fixture(autouse=True)
async def _isolate_state():
for row in await repo.list_swarm_hosts():
await repo.delete_swarm_host(row["uuid"])
await repo.set_state("deployment", None)
await _clear_fleet()
yield
await repo.set_state("deployment", None)
await _clear_fleet()
@pytest.mark.anyio
async def test_additive_default_appends_to_existing_fleet(client, auth_token, monkeypatch):
"""Two sequential deploys with replace_fleet unset → both deckies in state."""
async def test_additive_onto_existing_fleet_appends_not_wipes(client, auth_token, monkeypatch):
"""BUG-2 regression: an additive web deploy onto a fleet established out
of band (CLI/seed → fleet_deckies) appends rather than wiping it.
Previously the guard read State["deployment"] (empty for a CLI-seeded
fleet), so existing_deckies was [] and the reconciler tore the running
fleet down to the single submitted decky."""
monkeypatch.setenv("DECNET_MODE", "master")
await _seed_fleet("decky-01", ip="192.168.1.10")
r1 = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": "[decky-01]\nservices = ssh\n"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r1.status_code == 202, r1.text
r2 = await client.post(
r = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": "[decky-02]\nservices = http\n"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r2.status_code == 202, r2.text
assert r.status_code == 202, r.text
committed = await repo.get_state("deployment")
assert committed is not None
names = {d["name"] for d in committed["config"]["deckies"]}
names = {d["name"] for d in await repo.get_deckies()}
assert names == {"decky-01", "decky-02"}
@pytest.mark.anyio
async def test_additive_name_collision_returns_409(client, auth_token, monkeypatch):
"""Re-submitting an existing decky name without replace_fleet → 409."""
"""Submitting a decky whose name already exists in the fleet without
replace_fleet → 409."""
monkeypatch.setenv("DECNET_MODE", "master")
await _seed_fleet("decky-01")
r1 = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": "[decky-01]\nservices = ssh\n"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r1.status_code == 202, r1.text
r2 = await client.post(
r = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": "[decky-01]\nservices = http\n"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r2.status_code == 409, r2.text
assert "decky-01" in r2.json()["detail"]
assert "replace_fleet" in r2.json()["detail"]
assert r.status_code == 409, r.text
assert "decky-01" in r.json()["detail"]
assert "replace_fleet" in r.json()["detail"]
@pytest.mark.anyio
async def test_additive_ip_collision_returns_409(client, auth_token, monkeypatch):
"""A new decky pinned to an IP already in use → 409 with the IP."""
"""A new decky pinned to an IP already in use by the existing fleet → 409
with the IP."""
monkeypatch.setenv("DECNET_MODE", "master")
await _seed_fleet("decky-01", ip="192.168.1.50")
r1 = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": "[decky-01]\nservices = ssh\nip = 192.168.1.50\n"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r1.status_code == 202, r1.text
r2 = await client.post(
r = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": "[decky-02]\nservices = http\nip = 192.168.1.50\n"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r2.status_code == 409, r2.text
assert "192.168.1.50" in r2.json()["detail"]
assert r.status_code == 409, r.text
assert "192.168.1.50" in r.json()["detail"]
@pytest.mark.anyio
async def test_replace_fleet_true_overwrites_existing(client, auth_token, monkeypatch):
"""replace_fleet=True preserves the historical full-replace semantics."""
"""replace_fleet=True preserves the historical full-replace semantics:
the existing fleet is dropped and the committed inventory is exactly the
submitted INI."""
monkeypatch.setenv("DECNET_MODE", "master")
await _seed_fleet("decky-01")
r1 = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": "[decky-01]\nservices = ssh\n"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r1.status_code == 202, r1.text
r2 = await client.post(
r = await client.post(
"/api/v1/deckies/deploy",
json={
"ini_content": "[decky-02]\nservices = http\n",
@@ -125,11 +149,9 @@ async def test_replace_fleet_true_overwrites_existing(client, auth_token, monkey
},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r2.status_code == 202, r2.text
assert r.status_code == 202, r.text
committed = await repo.get_state("deployment")
assert committed is not None
names = {d["name"] for d in committed["config"]["deckies"]}
names = {d["name"] for d in await repo.get_deckies()}
assert names == {"decky-02"}
@@ -139,25 +161,16 @@ async def test_additive_lifecycle_ids_scoped_to_new_deckies(client, auth_token,
the caller submitted, not carryover. Operators polling
/deckies/lifecycle?ids=... see exactly what this call deployed."""
monkeypatch.setenv("DECNET_MODE", "master")
await _seed_fleet("decky-01", ip="192.168.1.10")
await _seed_fleet("decky-02", ip="192.168.1.11")
r1 = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": "[decky-01]\nservices = ssh\n[decky-02]\nservices = http\n"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r1.status_code == 202, r1.text
assert len(r1.json()["lifecycle_ids"]) == 2
r2 = await client.post(
r = await client.post(
"/api/v1/deckies/deploy",
json={"ini_content": "[decky-03]\nservices = ssh\n"},
headers={"Authorization": f"Bearer {auth_token}"},
)
assert r2.status_code == 202, r2.text
body2 = r2.json()
assert len(body2["lifecycle_ids"]) == 1
assert r.status_code == 202, r.text
assert len(r.json()["lifecycle_ids"]) == 1
committed = await repo.get_state("deployment")
assert committed is not None
names = {d["name"] for d in committed["config"]["deckies"]}
names = {d["name"] for d in await repo.get_deckies()}
assert names == {"decky-01", "decky-02", "decky-03"}

View File

@@ -5,7 +5,7 @@ from hypothesis import given, settings, strategies as st
from ..conftest import _FUZZ_SETTINGS
@pytest.mark.anyio
async def test_get_deckies_endpoint(mock_state_file, client: httpx.AsyncClient, auth_token: str):
async def test_get_deckies_endpoint(mock_fleet_deckies, client: httpx.AsyncClient, auth_token: str):
_response = await client.get("/api/v1/deckies", headers={"Authorization": f"Bearer {auth_token}"})
assert _response.status_code == 200
_data = _response.json()