From c266d1b6e34cf7dcebeaaa7e6fc47abdd48fff21 Mon Sep 17 00:00:00 2001 From: anti Date: Tue, 21 Apr 2026 20:13:39 -0400 Subject: [PATCH] =?UTF-8?q?feat(mutator,web):=20add=5Fdecky=20op=20?= =?UTF-8?q?=E2=80=94=20create-and-attach=20in=20one=20mutation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- decnet/cli/topology.py | 4 +- decnet/mutator/ops.py | 74 +++++++++++++++++++ decnet/web/db/models.py | 5 +- decnet_web/src/components/MazeNET/MazeNET.tsx | 56 +++++++------- .../src/components/MazeNET/useMazeApi.ts | 2 +- .../components/MazeNET/useTopologyEditor.ts | 43 +++++++++-- tests/topology/test_mutator.py | 61 ++++++++++++++- 7 files changed, 202 insertions(+), 43 deletions(-) diff --git a/decnet/cli/topology.py b/decnet/cli/topology.py index e2c8960e..f7cfe122 100644 --- a/decnet/cli/topology.py +++ b/decnet/cli/topology.py @@ -267,8 +267,8 @@ def _mutate( op: str = typer.Argument( ..., help=( - "One of: add_lan, remove_lan, attach_decky, detach_decky, " - "remove_decky, update_decky, update_lan" + "One of: add_lan, remove_lan, add_decky, attach_decky, " + "detach_decky, remove_decky, update_decky, update_lan" ), ), payload_json: str = typer.Option( diff --git a/decnet/mutator/ops.py b/decnet/mutator/ops.py index 013273a3..0029d00e 100644 --- a/decnet/mutator/ops.py +++ b/decnet/mutator/ops.py @@ -154,6 +154,78 @@ async def apply_remove_lan( await _assert_valid_after(repo, topology_id) +async def apply_add_decky( + repo: Any, topology_id: str, payload: dict[str, Any] +) -> None: + """Create a brand-new decky and attach it to its home LAN. + + Used when the editor drags an archetype onto an active topology. + ``apply_attach_decky`` requires an existing decky, so without this + op there is no way to grow a live topology from the UI. + + ``payload`` keys: + ``name`` — decky name (required, unique in topology). + ``lan`` — home LAN name (required). + ``services`` — list of service slugs (optional). + ``archetype`` — slug string; stored in ``decky_config`` (optional). + ``forwards_l3`` — bool; stored in ``decky_config`` (optional). + ``ip`` — pinned IP inside the LAN; else auto-allocated. + ``x``,``y`` — layout coords (optional). + """ + name = payload["name"] + hydrated = await _hydrated(repo, topology_id) + if _decky_by_name(hydrated, name) is not None: + raise MutationError(f"decky {name!r} already exists") + lan = _lan_by_name(hydrated, payload["lan"]) + if lan is None: + raise MutationError(f"LAN {payload['lan']!r} not found") + + ip = payload.get("ip") + if ip is None: + taken = { + d["decky_config"]["ips_by_lan"].get(lan["name"]) + for d in hydrated["deckies"] + if lan["name"] in d["decky_config"].get("ips_by_lan", {}) + } + taken.discard(None) + alloc = IPAllocator(subnet=lan["subnet"]) + for t in taken: + if t: + alloc.reserve(t) + ip = alloc.next_free() + + decky_config: dict[str, Any] = { + "name": name, + "ips_by_lan": {lan["name"]: ip}, + } + if "archetype" in payload: + decky_config["archetype"] = payload["archetype"] + forwards_l3 = bool(payload.get("forwards_l3", False)) + if forwards_l3: + decky_config["forwards_l3"] = True + + decky_uuid = await repo.add_topology_decky( + { + "topology_id": topology_id, + "name": name, + "services": list(payload.get("services", [])), + "decky_config": decky_config, + "x": payload.get("x"), + "y": payload.get("y"), + } + ) + await repo.add_topology_edge( + { + "topology_id": topology_id, + "decky_uuid": decky_uuid, + "lan_id": lan["id"], + "is_bridge": False, + "forwards_l3": forwards_l3, + } + ) + await _assert_valid_after(repo, topology_id) + + async def apply_attach_decky( repo: Any, topology_id: str, payload: dict[str, Any] ) -> None: @@ -326,6 +398,7 @@ async def apply_update_lan( DISPATCH: dict[str, OpFunc] = { "add_lan": apply_add_lan, "remove_lan": apply_remove_lan, + "add_decky": apply_add_decky, "attach_decky": apply_attach_decky, "detach_decky": apply_detach_decky, "remove_decky": apply_remove_decky, @@ -358,6 +431,7 @@ __all__ = [ "dispatch", "apply_add_lan", "apply_remove_lan", + "apply_add_decky", "apply_attach_decky", "apply_detach_decky", "apply_remove_decky", diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py index 3dc9902e..fd90f119 100644 --- a/decnet/web/db/models.py +++ b/decnet/web/db/models.py @@ -339,8 +339,8 @@ class TopologyMutation(SQLModel, table=True): ) id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True) topology_id: str = Field(foreign_key="topologies.id", index=True) - # 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 op: str = Field(index=True) # JSON-serialised op payload (keys depend on ``op``). payload: str = Field( @@ -805,6 +805,7 @@ class EdgeCreateRequest(BaseModel): _MUTATION_OPS = Literal[ "add_lan", "remove_lan", + "add_decky", "attach_decky", "detach_decky", "remove_decky", diff --git a/decnet_web/src/components/MazeNET/MazeNET.tsx b/decnet_web/src/components/MazeNET/MazeNET.tsx index 13bb9863..6522cda1 100644 --- a/decnet_web/src/components/MazeNET/MazeNET.tsx +++ b/decnet_web/src/components/MazeNET/MazeNET.tsx @@ -96,16 +96,15 @@ const MazeNET: React.FC = () => { if (isDmz) { const gwName = `dmz-gateway-${hex4()}`; - const gwRes = await editor.createDecky(topologyId, { - name: gwName, services: ['ssh'], x: 20, y: 40, - decky_config: { archetype: 'deaddeck', forwards_l3: true }, - }); + const gwRes = await editor.addDeckyToLan( + topologyId, + { name: gwName, services: ['ssh'], x: 20, y: 40, + decky_config: { archetype: 'deaddeck', forwards_l3: true } }, + lan.id, lan.name, + { is_bridge: true, forwards_l3: true }, + ); if (gwRes.kind !== 'applied') return; const gw = gwRes.data; - await editor.attachEdge(topologyId, { - decky_uuid: gw.uuid, lan_id: lan.id, - is_bridge: true, forwards_l3: true, - }, gw.name, lan.name); const gwNode: DeckyNode = { kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name, archetype: 'deaddeck', services: ['ssh'], status: 'idle', @@ -130,15 +129,14 @@ const MazeNET: React.FC = () => { const ny = Math.max(28, Math.round(world.y - net.y - 24)); const name = `decky-${hex4()}`; try { - const dRes = await editor.createDecky(topologyId, { - name, services: dServices, x: nx, y: ny, - decky_config: { archetype: archSlug }, - }); + const dRes = await editor.addDeckyToLan( + topologyId, + { name, services: dServices, x: nx, y: ny, + decky_config: { archetype: archSlug } }, + overNetId, net.label, + ); if (dRes.kind !== 'applied') return; const decky = dRes.data; - await editor.attachEdge(topologyId, - { decky_uuid: decky.uuid, lan_id: overNetId }, - decky.name, net.label); const node: DeckyNode = { kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name, archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny, @@ -256,16 +254,15 @@ const MazeNET: React.FC = () => { if (!n || n.kind !== 'decky') return; const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`; try { - const dRes = await editor.createDecky(topologyId, { - name, services: [...n.services], x: n.x + 24, y: n.y + 24, - decky_config: { archetype: n.archetype }, - }); + const parentNet = nets.find((net) => net.id === n.netId); + const dRes = await editor.addDeckyToLan( + topologyId, + { name, services: [...n.services], x: n.x + 24, y: n.y + 24, + decky_config: { archetype: n.archetype } }, + n.netId, parentNet?.label ?? '', + ); if (dRes.kind !== 'applied') return; const decky = dRes.data; - const parentNet = nets.find((net) => net.id === n.netId); - await editor.attachEdge(topologyId, - { decky_uuid: decky.uuid, lan_id: n.netId }, - decky.name, parentNet?.label ?? ''); const copy: DeckyNode = { kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name, archetype: n.archetype, services: [...n.services], status: 'idle', @@ -351,15 +348,14 @@ const MazeNET: React.FC = () => { onClick: async () => { const name = `decky-${hex4()}`; try { - const dRes = await editor.createDecky(topologyId, { - name, services: [...a.services], x: 20, y: 40, - decky_config: { archetype: a.slug }, - }); + const dRes = await editor.addDeckyToLan( + topologyId, + { name, services: [...a.services], x: 20, y: 40, + decky_config: { archetype: a.slug } }, + id, net.label, + ); if (dRes.kind !== 'applied') return; const decky = dRes.data; - await editor.attachEdge(topologyId, - { decky_uuid: decky.uuid, lan_id: id }, - decky.name, net.label); const node: DeckyNode = { kind: 'decky', id: decky.uuid, netId: id, name: decky.name, archetype: a.slug, services: [...a.services], status: 'idle', diff --git a/decnet_web/src/components/MazeNET/useMazeApi.ts b/decnet_web/src/components/MazeNET/useMazeApi.ts index 72e89b9b..96e11730 100644 --- a/decnet_web/src/components/MazeNET/useMazeApi.ts +++ b/decnet_web/src/components/MazeNET/useMazeApi.ts @@ -195,7 +195,7 @@ export interface CreateDeckyBody { export type MutationOp = | 'add_lan' | 'remove_lan' | 'update_lan' - | 'attach_decky' | 'detach_decky' | 'remove_decky' | 'update_decky'; + | 'add_decky' | 'attach_decky' | 'detach_decky' | 'remove_decky' | 'update_decky'; export interface EnqueueMutationResponse { mutation_id: string; diff --git a/decnet_web/src/components/MazeNET/useTopologyEditor.ts b/decnet_web/src/components/MazeNET/useTopologyEditor.ts index a8e16cad..588db5cf 100644 --- a/decnet_web/src/components/MazeNET/useTopologyEditor.ts +++ b/decnet_web/src/components/MazeNET/useTopologyEditor.ts @@ -52,6 +52,17 @@ export interface UseTopologyEditor { ): Promise>; createDecky(topologyId: string, body: CreateDeckyBody): Promise>; + /** Composite: create a decky and attach it to its home LAN. On pending + * this is two CRUD calls; on active it's one ``add_decky`` enqueue. + * Callers should prefer this over ``createDecky`` + ``attachEdge`` so + * the active path doesn't 409 on the CRUD half. */ + addDeckyToLan( + topologyId: string, + body: CreateDeckyBody, + lanId: string, + lanName: string, + opts?: { is_bridge?: boolean; forwards_l3?: boolean }, + ): Promise>; updateDecky( topologyId: string, uuid: string, @@ -128,14 +139,36 @@ export function useTopologyEditor( // ── Decky ────────────────────────────────────────────────────────── async createDecky(topologyId, body) { - // No add_decky mutation op — decky creation on active topologies - // is a composite (attach_decky with the create implicit). Phase B - // step 3 handles that; for now creation stays direct-CRUD so the - // pending path keeps working. On active this will 409 today until - // step 3 lands a combined flow. + // Bare create — only valid on pending. On active callers should use + // addDeckyToLan() instead; the backend guard will 409 here. const data = await api.createDecky(topologyId, body); return { kind: 'applied', data }; }, + async addDeckyToLan(topologyId, body, lanId, lanName, opts) { + if (!live) { + const data = await api.createDecky(topologyId, body); + await api.attachEdge(topologyId, { + decky_uuid: data.uuid, + lan_id: lanId, + is_bridge: opts?.is_bridge, + forwards_l3: opts?.forwards_l3, + }); + return { kind: 'applied', data }; + } + const payload: Record = { + name: body.name, + lan: lanName, + services: body.services, + }; + const cfg = body.decky_config ?? {}; + if (cfg.archetype !== undefined) payload.archetype = cfg.archetype; + const fwd = opts?.forwards_l3 ?? cfg.forwards_l3; + if (fwd !== undefined) payload.forwards_l3 = fwd; + if (body.x !== undefined) payload.x = body.x; + if (body.y !== undefined) payload.y = body.y; + const res = await api.enqueueMutation(topologyId, 'add_decky', payload, topoVersion); + return { kind: 'enqueued', mutationId: res.mutation_id }; + }, async updateDecky(topologyId, uuid, deckyName, patch) { if (!live) { const data = await api.updateDecky(topologyId, uuid, patch); diff --git a/tests/topology/test_mutator.py b/tests/topology/test_mutator.py index 87ca7b26..e765f9a0 100644 --- a/tests/topology/test_mutator.py +++ b/tests/topology/test_mutator.py @@ -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", }