fix(topology): backfill decky_config name and ips_by_lan in hydrate
UI-created deckies (api_decky_crud, api_create_blank_topology) write decky_config as sent by the client — typically just archetype flags, without the name/ips_by_lan fields compose.py requires. The generator path populates them at persist() time, so compose worked for generated topologies but KeyError'd on UI-created ones. Normalise in hydrate() so every write path feeds the same shape downstream: mirror decky.name into decky_config.name, and allocate per-LAN IPs deterministically (reserving the primary decky.ip where it falls in-subnet, then filling remaining edges with next_free).
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
"""Adapter between :class:`GeneratedTopology` and the repository layer."""
|
"""Adapter between :class:`GeneratedTopology` and the repository layer."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ipaddress import IPv4Address, IPv4Network
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from decnet.topology.allocator import IPAllocator
|
||||||
from decnet.topology.config import GeneratedTopology
|
from decnet.topology.config import GeneratedTopology
|
||||||
from decnet.topology.status import TopologyStatus, assert_transition
|
from decnet.topology.status import TopologyStatus, assert_transition
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ async def hydrate(repo: Any, topology_id: str) -> dict[str, Any] | None:
|
|||||||
lans = await repo.list_lans_for_topology(topology_id)
|
lans = await repo.list_lans_for_topology(topology_id)
|
||||||
deckies = await repo.list_topology_deckies(topology_id)
|
deckies = await repo.list_topology_deckies(topology_id)
|
||||||
edges = await repo.list_topology_edges(topology_id)
|
edges = await repo.list_topology_edges(topology_id)
|
||||||
|
_backfill_decky_configs(lans, deckies, edges)
|
||||||
return {
|
return {
|
||||||
"topology": topo,
|
"topology": topo,
|
||||||
"lans": lans,
|
"lans": lans,
|
||||||
@@ -123,6 +126,83 @@ async def hydrate(repo: Any, topology_id: str) -> dict[str, Any] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_decky_configs(
|
||||||
|
lans: list[dict[str, Any]],
|
||||||
|
deckies: list[dict[str, Any]],
|
||||||
|
edges: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""Fill in ``decky_config['name']`` and ``ips_by_lan`` for UI-created rows.
|
||||||
|
|
||||||
|
The generator path writes these fields at persist-time; the REST
|
||||||
|
CRUD path writes whatever the client sends (often just archetype
|
||||||
|
flags). Compose generation requires both, so we normalise here so
|
||||||
|
every write path feeds the same shape downstream.
|
||||||
|
"""
|
||||||
|
lans_by_id = {lan["id"]: lan for lan in lans}
|
||||||
|
allocators: dict[str, IPAllocator] = {}
|
||||||
|
|
||||||
|
def _alloc(lan_id: str) -> IPAllocator | None:
|
||||||
|
lan = lans_by_id.get(lan_id)
|
||||||
|
if lan is None or not lan.get("subnet"):
|
||||||
|
return None
|
||||||
|
if lan_id not in allocators:
|
||||||
|
allocators[lan_id] = IPAllocator(lan["subnet"])
|
||||||
|
return allocators[lan_id]
|
||||||
|
|
||||||
|
decky_edges: dict[str, list[str]] = {}
|
||||||
|
for e in edges:
|
||||||
|
decky_edges.setdefault(e["decky_uuid"], []).append(e["lan_id"])
|
||||||
|
|
||||||
|
ordered = sorted(deckies, key=lambda d: (d.get("name", ""), d["uuid"]))
|
||||||
|
|
||||||
|
# Pass 1: reserve IPs already declared in decky_config.
|
||||||
|
for decky in ordered:
|
||||||
|
cfg = decky.get("decky_config") or {}
|
||||||
|
existing = cfg.get("ips_by_lan") or {}
|
||||||
|
for lan_id in decky_edges.get(decky["uuid"], []):
|
||||||
|
lan = lans_by_id.get(lan_id)
|
||||||
|
if lan is None:
|
||||||
|
continue
|
||||||
|
alloc = _alloc(lan_id)
|
||||||
|
if alloc is None:
|
||||||
|
continue
|
||||||
|
ip = existing.get(lan["name"])
|
||||||
|
if ip and alloc.is_free(ip):
|
||||||
|
alloc.reserve(ip)
|
||||||
|
|
||||||
|
# Pass 2: fill gaps; rewrite decky_config.
|
||||||
|
for decky in ordered:
|
||||||
|
cfg = dict(decky.get("decky_config") or {})
|
||||||
|
cfg.setdefault("name", decky.get("name"))
|
||||||
|
ips_by_lan: dict[str, str] = dict(cfg.get("ips_by_lan") or {})
|
||||||
|
primary_ip = decky.get("ip")
|
||||||
|
for lan_id in decky_edges.get(decky["uuid"], []):
|
||||||
|
lan = lans_by_id.get(lan_id)
|
||||||
|
if lan is None:
|
||||||
|
continue
|
||||||
|
if lan["name"] in ips_by_lan:
|
||||||
|
continue
|
||||||
|
alloc = _alloc(lan_id)
|
||||||
|
if alloc is None:
|
||||||
|
continue
|
||||||
|
ip: str | None = None
|
||||||
|
if primary_ip:
|
||||||
|
try:
|
||||||
|
if (
|
||||||
|
IPv4Address(primary_ip) in IPv4Network(lan["subnet"])
|
||||||
|
and alloc.is_free(primary_ip)
|
||||||
|
):
|
||||||
|
ip = primary_ip
|
||||||
|
alloc.reserve(ip)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if ip is None:
|
||||||
|
ip = alloc.next_free()
|
||||||
|
ips_by_lan[lan["name"]] = ip
|
||||||
|
cfg["ips_by_lan"] = ips_by_lan
|
||||||
|
decky["decky_config"] = cfg
|
||||||
|
|
||||||
|
|
||||||
# Re-export the status constants so callers can ``from decnet.topology.persistence
|
# Re-export the status constants so callers can ``from decnet.topology.persistence
|
||||||
# import TopologyStatus`` without chasing modules.
|
# import TopologyStatus`` without chasing modules.
|
||||||
__all__ = ["persist", "transition_status", "hydrate", "TopologyStatus"]
|
__all__ = ["persist", "transition_status", "hydrate", "TopologyStatus"]
|
||||||
|
|||||||
Reference in New Issue
Block a user