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.
177 lines
6.4 KiB
Python
177 lines
6.4 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""POST /deckies/deploy additive vs replace_fleet semantics.
|
|
|
|
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
|
|
|
|
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
|
|
|
|
|
|
@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
|
|
|
|
|
|
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_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")
|
|
|
|
r = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={"ini_content": "[decky-02]\nservices = http\n"},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
assert r.status_code == 202, r.text
|
|
|
|
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):
|
|
"""Submitting a decky whose name already exists in the fleet without
|
|
replace_fleet → 409."""
|
|
monkeypatch.setenv("DECNET_MODE", "master")
|
|
await _seed_fleet("decky-01")
|
|
|
|
r = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={"ini_content": "[decky-01]\nservices = http\n"},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
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 by the existing fleet → 409
|
|
with the IP."""
|
|
monkeypatch.setenv("DECNET_MODE", "master")
|
|
await _seed_fleet("decky-01", ip="192.168.1.50")
|
|
|
|
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 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:
|
|
the existing fleet is dropped and the committed inventory is exactly the
|
|
submitted INI."""
|
|
monkeypatch.setenv("DECNET_MODE", "master")
|
|
await _seed_fleet("decky-01")
|
|
|
|
r = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={
|
|
"ini_content": "[decky-02]\nservices = http\n",
|
|
"replace_fleet": True,
|
|
},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
assert r.status_code == 202, r.text
|
|
|
|
names = {d["name"] for d in await repo.get_deckies()}
|
|
assert names == {"decky-02"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_additive_lifecycle_ids_scoped_to_new_deckies(client, auth_token, monkeypatch):
|
|
"""In additive mode the response's lifecycle_ids cover only the deckies
|
|
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")
|
|
|
|
r = await client.post(
|
|
"/api/v1/deckies/deploy",
|
|
json={"ini_content": "[decky-03]\nservices = ssh\n"},
|
|
headers={"Authorization": f"Bearer {auth_token}"},
|
|
)
|
|
assert r.status_code == 202, r.text
|
|
assert len(r.json()["lifecycle_ids"]) == 1
|
|
|
|
names = {d["name"] for d in await repo.get_deckies()}
|
|
assert names == {"decky-01", "decky-02", "decky-03"}
|