feat(orchestrator): slice 13c(ii) — shared-gateway source-IP allocator
Deterministic per-bottle address assignment on the consolidated docker gateway's shared network — the address the gateway uses as its attribution key. Pure ipaddress logic; the docker-specific inputs (subnet CIDR, the gateway container's own address) are gathered by the caller and passed as `taken`, keeping it testable without docker. - next_free_ip(cidr, taken): lowest host address not reserved (network + broadcast excluded by hosts(); docker's router .1 excluded here) and not already assigned (gateway container + live bottles from the registry). NoFreeAddressError when the subnet is exhausted. pyright 0 errors; pylint 9.83/10; unit suite green (the 13 test_sidecar_init /bin/sleep errors are pre-existing NixOS-local noise). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user