"""Live-mutation ops for active MazeNET topologies. Each ``apply_`` 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_`` 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", ]