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.
This commit is contained in:
@@ -118,7 +118,7 @@ async def apply_add_lan(
|
||||
|
||||
if subnet is None:
|
||||
reserved = await reserved_subnets(repo)
|
||||
alloc = SubnetAllocator(base_prefix="172.20", reserved=reserved)
|
||||
alloc = SubnetAllocator(base_prefix="172.16.0.0/12", reserved=reserved)
|
||||
subnet = alloc.next_free()
|
||||
|
||||
await repo.add_lan(
|
||||
|
||||
@@ -69,31 +69,47 @@ class IPAllocator:
|
||||
|
||||
|
||||
class SubnetAllocator:
|
||||
"""Hands out ``/24`` subnets under a base prefix (e.g. ``172.20``)."""
|
||||
"""Hands out ``/24`` subnets inside a parent network.
|
||||
|
||||
_MAX_INDEX = 256 # 172.20.0/24 .. 172.20.255/24
|
||||
Accepted ``base_prefix`` forms:
|
||||
|
||||
* Full CIDR: ``"172.16.0.0/12"`` → 4096 ``/24`` slots
|
||||
* Legacy two-octet shorthand: ``"172.20"`` → auto-lifted to
|
||||
``"172.20.0.0/16"`` (256 slots), for backward compat with
|
||||
configs written before mass-scale topologies were a thing.
|
||||
|
||||
The parent must be at most ``/24`` wide (i.e. its prefix length
|
||||
must be ≤ 24); a ``/24`` base yields exactly one slot, anything
|
||||
larger yields more.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_prefix: str,
|
||||
reserved: Iterable[str] = (),
|
||||
) -> None:
|
||||
self._base = base_prefix.rstrip(".")
|
||||
parent = _parse_base(base_prefix)
|
||||
if parent.prefixlen > 24:
|
||||
raise ValueError(
|
||||
f"subnet base {parent.with_prefixlen} is narrower than /24; "
|
||||
"cannot carve /24 children out of it"
|
||||
)
|
||||
self._parent = parent
|
||||
# A generator over all /24 subnets of the parent. ipaddress
|
||||
# yields them in order, so the allocator preserves the legacy
|
||||
# "sequential-third-octet" behaviour for /16 bases. For /12
|
||||
# bases you get second.third-octet sweep.
|
||||
self._iter = parent.subnets(new_prefix=24) if parent.prefixlen < 24 else iter([parent])
|
||||
self._reserved: set[str] = {s for s in reserved}
|
||||
self._cursor = 0
|
||||
|
||||
def _candidate(self, idx: int) -> str:
|
||||
return f"{self._base}.{idx}.0/24"
|
||||
|
||||
def next_free(self) -> str:
|
||||
while self._cursor < self._MAX_INDEX:
|
||||
subnet = self._candidate(self._cursor)
|
||||
self._cursor += 1
|
||||
for net in self._iter:
|
||||
subnet = net.with_prefixlen
|
||||
if subnet not in self._reserved:
|
||||
self._reserved.add(subnet)
|
||||
return subnet
|
||||
raise AllocatorExhausted(
|
||||
f"no free /24s left under {self._base}.0.0/16"
|
||||
f"no free /24s left under {self._parent.with_prefixlen}"
|
||||
)
|
||||
|
||||
def reserve(self, subnet: str) -> None:
|
||||
@@ -103,6 +119,21 @@ class SubnetAllocator:
|
||||
return subnet not in self._reserved
|
||||
|
||||
|
||||
def _parse_base(base_prefix: str) -> IPv4Network:
|
||||
"""Accept either ``'a.b.c.d/n'`` or legacy ``'a.b'`` shorthand."""
|
||||
stripped = base_prefix.strip().rstrip(".")
|
||||
if "/" in stripped:
|
||||
return IPv4Network(stripped, strict=False)
|
||||
octets = stripped.split(".")
|
||||
if len(octets) == 2:
|
||||
return IPv4Network(f"{stripped}.0.0/16", strict=False)
|
||||
if len(octets) == 4:
|
||||
return IPv4Network(f"{stripped}/24", strict=False)
|
||||
raise ValueError(
|
||||
f"unrecognised subnet base {base_prefix!r}; expected 'x.y' or CIDR"
|
||||
)
|
||||
|
||||
|
||||
# Topology statuses whose LANs still claim subnets. torn_down is the
|
||||
# only state that releases its networks back to the pool.
|
||||
_SUBNET_CLAIMING_STATES: frozenset[str] = frozenset(
|
||||
|
||||
@@ -30,8 +30,16 @@ class TopologyConfig(BaseModel):
|
||||
# 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 starting here.
|
||||
subnet_base_prefix: str = Field(default="172.20", pattern=r"^\d{1,3}\.\d{1,3}$")
|
||||
# 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)
|
||||
|
||||
@@ -85,7 +85,10 @@ async def api_list_archetypes(
|
||||
)
|
||||
@_traced("api.topology.catalog.next_subnet")
|
||||
async def api_next_subnet(
|
||||
base: str = Query(default="172.20", pattern=r"^\d{1,3}\.\d{1,3}$"),
|
||||
base: str = Query(
|
||||
default="172.16.0.0/12",
|
||||
pattern=r"^\d{1,3}\.\d{1,3}(\.\d{1,3}\.\d{1,3}/\d{1,2})?$",
|
||||
),
|
||||
_viewer: dict = Depends(require_viewer),
|
||||
) -> NextSubnetResponse:
|
||||
reserved = await reserved_subnets(repo)
|
||||
|
||||
Reference in New Issue
Block a user