"""Shared-gateway source-IP allocation for the consolidated docker backend (PRD 0070). In the consolidated model one gateway container serves every bottle over a single shared docker network, and each agent bottle attaches with a pinned, deterministic address that the gateway uses as its **attribution key**. This allocates those addresses from the network's subnet, skipping the reserved ones — the network address and broadcast (excluded by `hosts()`), docker's router `.1`, and everything already in use (`taken`: the gateway container plus every live bottle, which the caller reads from the registry). Pure `ipaddress` logic — the docker-specific bits (the subnet CIDR, the gateway container's own address) are gathered by the caller and passed in, so this stays testable without docker. """ from __future__ import annotations import ipaddress from collections.abc import Iterable class NoFreeAddressError(RuntimeError): """The shared gateway network's subnet is exhausted — every host address is reserved or already assigned to a bottle.""" def next_free_ip(cidr: str, taken: Iterable[str]) -> str: """The lowest host address in `cidr` not in `taken` and not docker's router (`.1`). `taken` must include the gateway container's own address and every live bottle's. Raises `NoFreeAddressError` if the subnet is full.""" net = ipaddress.ip_network(cidr, strict=False) reserved = {str(a) for a in taken} # Docker assigns the network's first host (.1) to the bridge router; a # bottle must never be handed that address. reserved.add(str(net.network_address + 1)) for host in net.hosts(): # hosts() already excludes network + broadcast candidate = str(host) if candidate not in reserved: return candidate raise NoFreeAddressError( f"no free address in {cidr} ({len(reserved)} reserved/assigned)" ) __all__ = ["next_free_ip", "NoFreeAddressError"]