diff --git a/decnet/mutator/ops.py b/decnet/mutator/ops.py index 0029d00e..245bc94a 100644 --- a/decnet/mutator/ops.py +++ b/decnet/mutator/ops.py @@ -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( diff --git a/decnet/topology/allocator.py b/decnet/topology/allocator.py index 2749688b..7600c730 100644 --- a/decnet/topology/allocator.py +++ b/decnet/topology/allocator.py @@ -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( diff --git a/decnet/topology/config.py b/decnet/topology/config.py index 1f2098ec..373dc8ea 100644 --- a/decnet/topology/config.py +++ b/decnet/topology/config.py @@ -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) diff --git a/decnet/web/router/topology/api_catalog.py b/decnet/web/router/topology/api_catalog.py index 858640e8..74f42ee7 100644 --- a/decnet/web/router/topology/api_catalog.py +++ b/decnet/web/router/topology/api_catalog.py @@ -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) diff --git a/tests/topology/test_allocator.py b/tests/topology/test_allocator.py index 472a81a4..c0e17ebd 100644 --- a/tests/topology/test_allocator.py +++ b/tests/topology/test_allocator.py @@ -86,6 +86,49 @@ def test_subnet_allocator_exhaustion_raises(): s.next_free() +def test_subnet_allocator_accepts_cidr_base(): + """Full-CIDR base form is equivalent to the legacy two-octet form.""" + s = SubnetAllocator("172.20.0.0/16") + assert s.next_free() == "172.20.0.0/24" + assert s.next_free() == "172.20.1.0/24" + + +def test_subnet_allocator_slash12_yields_more_than_256_slots(): + """The whole point of widening: a /12 base must outlast a single /16.""" + s = SubnetAllocator("172.16.0.0/12") + # Burn the first 256 /24s. With a /16 base this is exhaustion; with + # /12 we should roll into 172.17.x.x without raising. + for _ in range(256): + s.next_free() + nxt = s.next_free() + assert nxt.startswith("172.17.") + assert nxt.endswith(".0/24") + + +def test_subnet_allocator_slash12_total_capacity_is_4096(): + s = SubnetAllocator("172.16.0.0/12") + count = 0 + try: + while True: + s.next_free() + count += 1 + except AllocatorExhausted: + pass + assert count == 4096 + + +def test_subnet_allocator_rejects_narrower_than_slash24(): + with pytest.raises(ValueError, match="narrower than /24"): + SubnetAllocator("192.168.1.0/25") + + +def test_subnet_allocator_exhausted_message_uses_parent_cidr(): + s = SubnetAllocator("172.20.0.0/24") # exactly one slot + s.next_free() + with pytest.raises(AllocatorExhausted, match="172.20.0.0/24"): + s.next_free() + + # --------------------------------------------------------------------- reserved_subnets