From 578cdf9e2ec58f5ff6e9d94d289612e8619baa02 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 29 Apr 2026 00:12:44 -0400 Subject: [PATCH] fix(mutator): reject hostile apply_update_lan changes on live topologies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subnet and is_dmz are pinned at deploy time — live deckies bind to the bridge with IPs allocated from the old subnet, and is_dmz flips the docker network's internal flag which can't be changed while containers are attached. Today the op happily wrote the new value into the DB and left docker on the old one, drifting the two surfaces. apply_update_lan now raises MutationError when topology status is active or degraded and the patch touches subnet or is_dmz. Coord (x/y) and rename updates still pass through; renames don't currently have a live caller and the bridge's docker name keys off the lan name in the renderer, so the next deploy will reconcile. This matches the posture taken by _materialise_lan_change for live LAN add/remove (commit 472c84b). --- decnet/mutator/ops.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/decnet/mutator/ops.py b/decnet/mutator/ops.py index 05cba111..efc9b296 100644 --- a/decnet/mutator/ops.py +++ b/decnet/mutator/ops.py @@ -482,7 +482,22 @@ async def apply_update_decky( async def apply_update_lan( repo: Any, topology_id: str, payload: dict[str, Any] ) -> None: - """Update LAN fields — subnet, is_dmz, coords, rename.""" + """Update LAN fields — subnet, is_dmz, coords, rename. + + Guard rail: ``subnet`` and ``is_dmz`` are pinned at deploy time. + Live deckies bind to the bridge with IPs allocated from the old + subnet (and ``is_dmz`` flips swap the bridge's ``internal=False`` + flag, which docker can't change on a network with active + containers). Reject those mutations on active/degraded topologies + rather than rewriting the DB into an incoherent state. + + Coord-only updates (``x``/``y``) are layout-only; let them through + unconditionally. Renames pass through too — the bridge's docker + name is keyed off ``_network_name(topology_id, lan_name)``, so a + rename would also need a rebuild — but rename isn't currently a + code path on active topologies; if the operator hits it we still + write the row and let the next deploy reconcile. + """ hydrated = await _hydrated(repo, topology_id) lan = _lan_by_name(hydrated, payload["name"]) if lan is None: @@ -493,6 +508,17 @@ async def apply_update_lan( fields[key] = payload[key] if not fields: return + + topology = await repo.get_topology(topology_id) + is_live = bool(topology) and topology.get("status") in ("active", "degraded") + if is_live: + hostile = {"subnet", "is_dmz"} & fields.keys() + if hostile: + raise MutationError( + f"cannot change {sorted(hostile)} on a deployed LAN; " + f"teardown + redeploy required" + ) + await repo.update_lan(lan["id"], fields) await _assert_valid_after(repo, topology_id)