Files
DECNET/decnet/mutator/ops.py
anti c266d1b6e3 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.
2026-04-21 20:13:39 -04:00

441 lines
15 KiB
Python

"""Live-mutation ops for active MazeNET topologies.
Each ``apply_<op>`` function consumes a claimed ``TopologyMutation``
payload, mutates the repo (and, best-effort, the underlying Docker
state), then re-runs :func:`decnet.topology.validate.validate` against
the post-apply hydrated view. If validation errors appear, the op is
reported as failed and the caller flips the topology to ``degraded`` —
we never leave the repo in an invalid state.
Design notes
------------
* All ops are *repo-first*. The reconciler's job is to converge Docker
toward the repo's desired state, so persisting intent first keeps the
system self-healing across master restarts.
* Docker calls are optional at the ops layer: the tests drive these
functions directly against an in-memory repo, and the reconciler
sidecar calls them in production where Docker is present. Every
Docker call is guarded so missing/unreachable Docker doesn't leave
the DB half-mutated.
* Ops intentionally do NOT perform optimistic-concurrency checks — the
enqueue step already carried the caller's ``expected_version``. The
reconciler is the sole writer from here on.
"""
from __future__ import annotations
import json
from typing import Any, Awaitable, Callable, Optional
from decnet.logging import get_logger
from decnet.topology.allocator import IPAllocator, reserved_subnets, SubnetAllocator
from decnet.topology.persistence import hydrate
from decnet.topology.validate import (
check_names_unique,
check_no_ip_collisions,
check_no_subnet_overlap,
check_service_config_shape,
check_services_known,
errors as _validation_errors,
)
# Post-apply validation intentionally excludes topology-shape rules
# (``check_all_lans_connected_to_dmz``, ``check_exactly_one_dmz``,
# ``check_no_orphan_deckies``) — those are legitimately transient
# during live editing (e.g. ``add_lan`` leaves the new LAN orphaned
# until the next ``attach_decky``). The deployer's full ``validate()``
# pass still runs at redeploy time. Invariants that MUST hold after
# every single op are kept here.
_POST_APPLY_CHECKS = (
check_names_unique,
check_no_ip_collisions,
check_no_subnet_overlap,
check_services_known,
check_service_config_shape,
)
_log = get_logger("mutator.ops")
class MutationError(RuntimeError):
"""Raised by an ``apply_<op>`` when the requested change is illegal."""
OpFunc = Callable[[Any, str, dict[str, Any]], Awaitable[None]]
# ----------------------------------------------------------------- helpers
async def _hydrated(repo: Any, topology_id: str) -> dict[str, Any]:
h = await hydrate(repo, topology_id)
if h is None:
raise MutationError(f"topology {topology_id!r} vanished mid-apply")
return h
async def _assert_valid_after(repo: Any, topology_id: str) -> None:
"""Re-hydrate and check invariants; raise :class:`MutationError` on errors."""
h = await _hydrated(repo, topology_id)
issues: list = []
for check in _POST_APPLY_CHECKS:
issues.extend(check(h))
bad = _validation_errors(issues)
if bad:
codes = ", ".join(sorted({i.code for i in bad}))
raise MutationError(
f"post-apply validation failed for {topology_id}: {codes}"
)
def _lan_by_name(hydrated: dict[str, Any], name: str) -> Optional[dict]:
return next((lan for lan in hydrated["lans"] if lan["name"] == name), None)
def _decky_by_name(hydrated: dict[str, Any], name: str) -> Optional[dict]:
return next(
(d for d in hydrated["deckies"] if d["decky_config"]["name"] == name),
None,
)
# ------------------------------------------------------------------- ops
async def apply_add_lan(
repo: Any, topology_id: str, payload: dict[str, Any]
) -> None:
"""Add a new LAN to an active topology.
``payload`` keys:
``name`` — LAN name (required).
``subnet`` — ``/24`` CIDR (optional; auto-allocated if missing).
``is_dmz`` — bool, default False.
``x``,``y`` — layout coords, optional.
"""
name = payload["name"]
subnet = payload.get("subnet")
is_dmz = bool(payload.get("is_dmz", False))
if subnet is None:
reserved = await reserved_subnets(repo)
alloc = SubnetAllocator(base_prefix="172.20", reserved=reserved)
subnet = alloc.next_free()
await repo.add_lan(
{
"topology_id": topology_id,
"name": name,
"subnet": subnet,
"is_dmz": is_dmz,
"x": payload.get("x"),
"y": payload.get("y"),
}
)
await _assert_valid_after(repo, topology_id)
async def apply_remove_lan(
repo: Any, topology_id: str, payload: dict[str, Any]
) -> None:
"""Remove a LAN; refuses when any decky has it as its home LAN."""
hydrated = await _hydrated(repo, topology_id)
lan = _lan_by_name(hydrated, payload["name"])
if lan is None:
raise MutationError(f"LAN {payload['name']!r} not found")
# Refuse if any decky's home (primary/first) LAN is this one.
for d in hydrated["deckies"]:
ips = d["decky_config"].get("ips_by_lan", {})
if ips and next(iter(ips)) == lan["name"]:
raise MutationError(
f"LAN {lan['name']!r} is the home LAN of decky "
f"{d['decky_config']['name']!r}; remove the decky first"
)
await repo.delete_lan(lan["id"])
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:
"""Attach an existing decky to an additional LAN (bridge edge).
``payload`` keys:
``decky`` — decky name.
``lan`` — LAN name.
``ip`` — optional pinned IP; else allocated inside the LAN.
``forwards_l3`` — bool, default False.
"""
hydrated = await _hydrated(repo, topology_id)
lan = _lan_by_name(hydrated, payload["lan"])
decky = _decky_by_name(hydrated, payload["decky"])
if lan is None:
raise MutationError(f"LAN {payload['lan']!r} not found")
if decky is None:
raise MutationError(f"decky {payload['decky']!r} not found")
# Guard against re-attaching.
for e in hydrated["edges"]:
if e["decky_uuid"] == decky["uuid"] and e["lan_id"] == lan["id"]:
raise MutationError(
f"decky {decky['decky_config']['name']!r} already on "
f"LAN {lan['name']!r}"
)
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()
new_cfg = dict(decky["decky_config"])
new_cfg["ips_by_lan"] = {**new_cfg.get("ips_by_lan", {}), lan["name"]: ip}
forwards_l3 = bool(payload.get("forwards_l3", False))
if forwards_l3:
new_cfg["forwards_l3"] = True
await repo.update_topology_decky(
decky["uuid"], {"decky_config": new_cfg}
)
# Adding a second edge makes the decky multi-homed (a bridge decky).
await repo.add_topology_edge(
{
"topology_id": topology_id,
"decky_uuid": decky["uuid"],
"lan_id": lan["id"],
"is_bridge": True,
"forwards_l3": forwards_l3,
}
)
await _assert_valid_after(repo, topology_id)
async def apply_detach_decky(
repo: Any, topology_id: str, payload: dict[str, Any]
) -> None:
"""Detach a decky from one of its non-home LANs."""
hydrated = await _hydrated(repo, topology_id)
lan = _lan_by_name(hydrated, payload["lan"])
decky = _decky_by_name(hydrated, payload["decky"])
if lan is None or decky is None:
raise MutationError("decky or LAN not found")
ips_by_lan = decky["decky_config"].get("ips_by_lan", {})
if not ips_by_lan:
raise MutationError("decky has no LAN memberships")
home_lan = next(iter(ips_by_lan))
if home_lan == lan["name"]:
raise MutationError(
f"cannot detach home LAN {home_lan!r}; use remove_decky"
)
edge = next(
(
e
for e in hydrated["edges"]
if e["decky_uuid"] == decky["uuid"] and e["lan_id"] == lan["id"]
),
None,
)
if edge is None:
raise MutationError(
f"decky not attached to LAN {lan['name']!r}"
)
new_cfg = dict(decky["decky_config"])
new_ips = dict(new_cfg.get("ips_by_lan", {}))
new_ips.pop(lan["name"], None)
new_cfg["ips_by_lan"] = new_ips
await repo.update_topology_decky(
decky["uuid"], {"decky_config": new_cfg}
)
await repo.delete_topology_edge(edge["id"])
await _assert_valid_after(repo, topology_id)
async def apply_remove_decky(
repo: Any, topology_id: str, payload: dict[str, Any]
) -> None:
hydrated = await _hydrated(repo, topology_id)
decky = _decky_by_name(hydrated, payload["decky"])
if decky is None:
raise MutationError(f"decky {payload['decky']!r} not found")
await repo.delete_topology_decky(decky["uuid"])
await _assert_valid_after(repo, topology_id)
async def apply_update_decky(
repo: Any, topology_id: str, payload: dict[str, Any]
) -> None:
"""Update decky config — services, service_config, forwards_l3, coords.
``payload`` keys:
``decky`` — decky name.
``patch`` — dict merged into existing ``decky_config``.
``services`` — replacement top-level services list.
``x``,``y`` — layout coords.
"""
hydrated = await _hydrated(repo, topology_id)
decky = _decky_by_name(hydrated, payload["decky"])
if decky is None:
raise MutationError(f"decky {payload['decky']!r} not found")
patch: dict[str, Any] = {}
if payload.get("patch"):
merged = dict(decky["decky_config"])
merged.update(payload["patch"])
patch["decky_config"] = merged
if "services" in payload:
patch["services"] = list(payload["services"])
for key in ("x", "y"):
if key in payload:
patch[key] = payload[key]
if not patch:
return
await repo.update_topology_decky(decky["uuid"], patch)
await _assert_valid_after(repo, topology_id)
async def apply_update_lan(
repo: Any, topology_id: str, payload: dict[str, Any]
) -> None:
"""Update LAN fields — subnet, is_dmz, coords, rename."""
hydrated = await _hydrated(repo, topology_id)
lan = _lan_by_name(hydrated, payload["name"])
if lan is None:
raise MutationError(f"LAN {payload['name']!r} not found")
fields = {k: v for k, v in payload.get("patch", {}).items()}
for key in ("x", "y"):
if key in payload:
fields[key] = payload[key]
if not fields:
return
await repo.update_lan(lan["id"], fields)
await _assert_valid_after(repo, topology_id)
# Keep the dispatch table in one place so the engine and CLI stay in
# sync without cross-imports.
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,
"update_decky": apply_update_decky,
"update_lan": apply_update_lan,
}
async def dispatch(
repo: Any,
topology_id: str,
op: str,
payload_raw: str | dict[str, Any],
) -> None:
"""Decode payload JSON (if a string) and run the matching op."""
if isinstance(payload_raw, str):
payload = json.loads(payload_raw) if payload_raw else {}
else:
payload = payload_raw
try:
fn = DISPATCH[op]
except KeyError as e:
raise MutationError(f"unknown op: {op!r}") from e
await fn(repo, topology_id, payload)
__all__ = [
"DISPATCH",
"MutationError",
"dispatch",
"apply_add_lan",
"apply_remove_lan",
"apply_add_decky",
"apply_attach_decky",
"apply_detach_decky",
"apply_remove_decky",
"apply_update_decky",
"apply_update_lan",
]