feat(mutator,web): add_decky op — create-and-attach in one mutation

apply_attach_decky requires an existing decky, so the MazeNET editor
had no way to grow a live topology: creating a new decky on active
topologies 409'd on the direct-CRUD createDecky call.

- Backend: new apply_add_decky that creates the decky row + its
  home-LAN edge atomically, auto-allocating an IP if none pinned.
  Post-apply validation still runs. Added to DISPATCH + _MUTATION_OPS
  Literal + CLI help text.
- Tests: 3 new ops tests (happy path, duplicate-name rejection,
  missing-LAN rejection) plus dispatch coverage update.
- Frontend: useTopologyEditor gains addDeckyToLan() composite. Pending
  routes through createDecky + attachEdge as before; active routes
  through a single add_decky enqueue. MazeNET.tsx drag-archetype,
  duplicate, DMZ-gateway, and ctx-menu add-decky paths all use the
  composite so active topologies stop 409'ing on new-decky drops.
This commit is contained in:
2026-04-21 20:13:39 -04:00
parent 8fd166470f
commit c266d1b6e3
7 changed files with 202 additions and 43 deletions

View File

@@ -9,7 +9,12 @@ import pytest
from decnet.bus import topics as _topics
from decnet.bus.fake import FakeBus
from decnet.mutator import engine as _engine
from decnet.mutator.ops import MutationError, apply_add_lan, apply_update_decky
from decnet.mutator.ops import (
MutationError,
apply_add_decky,
apply_add_lan,
apply_update_decky,
)
from decnet.topology.config import TopologyConfig
from decnet.topology.generator import generate
from decnet.topology.persistence import persist, transition_status
@@ -158,6 +163,55 @@ async def test_apply_add_lan_persists(repo):
assert "LAN-MUT" in names
@pytest.mark.anyio
async def test_apply_add_decky_creates_and_attaches(repo):
"""add_decky creates a new decky row + home-LAN edge in one op."""
tid = await _make_active(repo)
lans = await repo.list_lans_for_topology(tid)
home_lan = lans[0]
await apply_add_decky(
repo, tid,
{
"name": "new-decky-mut",
"lan": home_lan["name"],
"services": ["ssh"],
"archetype": "deaddeck",
},
)
deckies = await repo.list_topology_deckies(tid)
new = next((d for d in deckies if d["decky_config"]["name"] == "new-decky-mut"), None)
assert new is not None
assert new["services"] == ["ssh"]
assert new["decky_config"]["archetype"] == "deaddeck"
assert home_lan["name"] in new["decky_config"]["ips_by_lan"]
edges = await repo.list_topology_edges(tid)
assert any(e["decky_uuid"] == new["uuid"] and e["lan_id"] == home_lan["id"] for e in edges)
@pytest.mark.anyio
async def test_apply_add_decky_rejects_duplicate_name(repo):
tid = await _make_active(repo)
lans = await repo.list_lans_for_topology(tid)
existing = (await repo.list_topology_deckies(tid))[0]
with pytest.raises(MutationError, match="already exists"):
await apply_add_decky(
repo, tid,
{"name": existing["decky_config"]["name"], "lan": lans[0]["name"]},
)
@pytest.mark.anyio
async def test_apply_add_decky_rejects_missing_lan(repo):
tid = await _make_active(repo)
with pytest.raises(MutationError, match="not found"):
await apply_add_decky(
repo, tid, {"name": "orphan-decky", "lan": "nonexistent-lan"},
)
@pytest.mark.anyio
async def test_apply_update_decky_replaces_services(repo):
"""Top-level ``services`` payload key replaces the decky's services list."""
@@ -287,8 +341,9 @@ def test_ops_payload_shape_docstring_present():
from decnet.mutator.ops import DISPATCH
assert set(DISPATCH) == {
"add_lan", "remove_lan", "attach_decky", "detach_decky",
"remove_decky", "update_decky", "update_lan",
"add_lan", "remove_lan",
"add_decky", "attach_decky", "detach_decky", "remove_decky",
"update_decky", "update_lan",
}