Files
DECNET/decnet/topology/config.py
anti f3408d5e62 fix(topology/allocator): widen default subnet base to /12 for mass-scale
A 30-LAN generate request already fits in 172.20.0.0/16, but trees
with depth/branching that multiply past 256 (e.g. depth=6,
branching=4 ≈ 5k LANs) hit AllocatorExhausted before the first
write.

SubnetAllocator now accepts a full CIDR base ("172.16.0.0/12" →
4096 /24s) in addition to the legacy two-octet shorthand ("172.20",
auto-lifted to /16). The parent must be ≤/24; a /24 base yields
exactly one slot. Iteration order is preserved for /16 bases so
existing topologies keep their third-octet sweep; /12 adds a
second-octet dimension underneath.

Defaults bumped to 172.16.0.0/12: TopologyConfig.subnet_base_prefix,
/next-subnet query param, and the mutator's add-LAN fallback. The
field pattern widens to accept CIDR. create-blank and manual LAN
CRUD still use "10.0" (lifts to /16) — one DMZ LAN per topology,
256 is plenty.
2026-04-24 18:57:55 -04:00

114 lines
4.2 KiB
Python

"""MazeNET topology config + in-memory generation output."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from pydantic import BaseModel, Field, model_validator
class TopologyConfig(BaseModel):
"""Parameters driving :func:`decnet.topology.generator.generate`."""
name: str = Field(..., min_length=1, max_length=64)
mode: str = Field(default="unihost", pattern=r"^(unihost|agent)$")
# Topology shape
depth: int = Field(..., ge=1, le=16, description="Max depth from DMZ")
branching_factor: int = Field(..., ge=1, le=8, description="Max child LANs per LAN")
deckies_per_lan_min: int = Field(default=1, ge=0, le=32)
deckies_per_lan_max: int = Field(default=3, ge=1, le=32)
# Probability a given non-DMZ LAN's connection to its parent uses a
# bridge decky that forwards L3 (enables attacker pivot). Bridge
# existence between parent/child is implicit — every non-DMZ LAN
# has exactly one parent bridge. This controls *forwarding*, not
# the existence of the bridge.
bridge_forward_probability: float = Field(default=1.0, ge=0.0, le=1.0)
# Probability of injecting a DAG cross-edge: a decky also bridged
# from its LAN to a non-parent, non-child LAN. 0.0 yields a tree.
cross_edge_probability: float = Field(default=0.0, ge=0.0, le=1.0)
# IP allocation base. LANs get sequential /24s carved out of this
# network. Accepts either a full CIDR (e.g. ``172.16.0.0/12`` for
# 4096 slots) or the legacy two-octet shorthand ``172.20`` which
# auto-lifts to ``172.20.0.0/16`` (256 slots). Default is a /12
# so mass-scale topologies (depth/branching trees with >256 LANs)
# don't exhaust the pool on first generation.
subnet_base_prefix: str = Field(
default="172.16.0.0/12",
pattern=r"^\d{1,3}\.\d{1,3}(\.\d{1,3}\.\d{1,3}/\d{1,2})?$",
)
# Service selection — reuses decnet.fleet.build_deckies' randomizer.
randomize_services: bool = Field(default=True)
services_explicit: Optional[list[str]] = None
seed: Optional[int] = Field(default=None, ge=0)
@model_validator(mode="after")
def _check_min_max(self) -> "TopologyConfig":
if self.deckies_per_lan_min > self.deckies_per_lan_max:
raise ValueError(
"deckies_per_lan_min must be <= deckies_per_lan_max"
)
if not self.randomize_services and not self.services_explicit:
raise ValueError(
"either randomize_services=True or services_explicit must be set"
)
return self
@dataclass
class _PlannedLAN:
"""In-memory LAN record emitted by the generator."""
name: str
subnet: str
is_dmz: bool
parent: Optional[str] # name of parent LAN, None for DMZ
# Canvas coordinates — generator leaves them None; the web editor
# (or a future auto-layouter) fills them in.
x: Optional[float] = None
y: Optional[float] = None
@dataclass
class _PlannedDecky:
"""In-memory decky record emitted by the generator."""
name: str
services: list[str]
# Mapping LAN-name → assigned IP within that LAN's subnet.
ips_by_lan: dict[str, str] = field(default_factory=dict)
forwards_l3: bool = False # only meaningful when present on ≥2 LANs
# Per-service config overrides: {service_name: {field: value}}.
# Mirrors ``DeckyConfig.service_config`` from the flat-fleet path;
# services read these via ``compose_fragment(service_cfg=...)``.
service_config: dict[str, dict] = field(default_factory=dict)
# Canvas coordinates — see _PlannedLAN.x/y.
x: Optional[float] = None
y: Optional[float] = None
@dataclass
class _PlannedEdge:
"""In-memory (decky, LAN) membership edge."""
decky_name: str
lan_name: str
is_bridge: bool
forwards_l3: bool
@dataclass
class GeneratedTopology:
"""Full in-memory output of :func:`decnet.topology.generator.generate`.
Names are unique within the topology. No UUIDs are assigned here —
those are minted by :mod:`decnet.topology.persistence` when the
topology is written to the repo.
"""
config: TopologyConfig
lans: list[_PlannedLAN]
deckies: list[_PlannedDecky]
edges: list[_PlannedEdge]