feat(mazenet): host resolution + cross-host bridge guard
Adds resolve_lan_host(lan, topology) and partition_lans_by_host(h) in topology.persistence — the single source of truth every per-host caller (deployer, mutator, validator) consults to decide where a LAN belongs. Resolution: lan.host_uuid → topology.target_host_uuid → None (master). Adds validator rule BRIDGE_HOST_SPLIT: a multi-homed (bridge) decky attached to LANs that resolve to different hosts is rejected at deploy-time. A bridge decky is one container with NICs into multiple LANs; under the co-locate constraint (no overlay network), all those LANs must share a host.
This commit is contained in:
@@ -329,6 +329,57 @@ def check_no_host_port_collision(h: dict[str, Any]) -> list[ValidationIssue]:
|
||||
return issues
|
||||
|
||||
|
||||
def check_bridge_decky_same_host(
|
||||
h: dict[str, Any],
|
||||
) -> list[ValidationIssue]:
|
||||
"""A multi-homed (bridge) decky is one container — its LANs must
|
||||
therefore resolve to the same swarm host.
|
||||
|
||||
Without this, the deployer would have to either silently pick a
|
||||
host for the bridge container (orphaning IPs on the other host's
|
||||
LAN) or implement a cross-host overlay. The co-locate decision
|
||||
rules out the overlay, so we reject the topology up front.
|
||||
"""
|
||||
from decnet.topology.persistence import resolve_lan_host
|
||||
|
||||
topology = h.get("topology") or {}
|
||||
lans_by_id = {lan["id"]: lan for lan in h.get("lans", [])}
|
||||
deckies_by_uuid = {d["uuid"]: d for d in h.get("deckies", [])}
|
||||
decky_lans: dict[str, list[str]] = {}
|
||||
for edge in h.get("edges", []):
|
||||
decky_lans.setdefault(edge["decky_uuid"], []).append(edge["lan_id"])
|
||||
|
||||
issues: list[ValidationIssue] = []
|
||||
for decky_uuid, lan_ids in decky_lans.items():
|
||||
if len(lan_ids) < 2:
|
||||
continue
|
||||
hosts = {
|
||||
resolve_lan_host(lans_by_id[lid], topology)
|
||||
for lid in lan_ids
|
||||
if lid in lans_by_id
|
||||
}
|
||||
if len(hosts) > 1:
|
||||
decky = deckies_by_uuid.get(decky_uuid, {})
|
||||
issues.append(
|
||||
ValidationIssue(
|
||||
"error",
|
||||
"BRIDGE_HOST_SPLIT",
|
||||
f"bridge decky {decky.get('name', decky_uuid)!r} is "
|
||||
"attached to LANs assigned to different swarm hosts; "
|
||||
"a single container cannot span hosts",
|
||||
target={
|
||||
"decky": decky.get("name"),
|
||||
"lans": [
|
||||
lans_by_id[lid].get("name")
|
||||
for lid in lan_ids
|
||||
if lid in lans_by_id
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
# Pure-data rules. Host-state rules (like PORT_COLLISION) are
|
||||
# *not* listed here — they're called separately by the live deployer
|
||||
# so that unit tests exercising validate() stay hermetic.
|
||||
@@ -341,6 +392,7 @@ _RULES: list[Callable[[dict[str, Any]], list[ValidationIssue]]] = [
|
||||
check_no_subnet_overlap,
|
||||
check_services_known,
|
||||
check_service_config_shape,
|
||||
check_bridge_decky_same_host,
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user