From 2f71d48189049c8e6ef05028ee976b629c5871e6 Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 13 Jul 2026 21:20:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(orchestrator):=20slice=2013c(ii)=20?= =?UTF-8?q?=E2=80=94=20shared-gateway=20source-IP=20allocator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck --- bot_bottle/backend/docker/gateway_net.py | 47 ++++++++++++++++++++++++ tests/unit/test_gateway_net.py | 42 +++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 bot_bottle/backend/docker/gateway_net.py create mode 100644 tests/unit/test_gateway_net.py diff --git a/bot_bottle/backend/docker/gateway_net.py b/bot_bottle/backend/docker/gateway_net.py new file mode 100644 index 0000000..fe3b73a --- /dev/null +++ b/bot_bottle/backend/docker/gateway_net.py @@ -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"] diff --git a/tests/unit/test_gateway_net.py b/tests/unit/test_gateway_net.py new file mode 100644 index 0000000..3ff957a --- /dev/null +++ b/tests/unit/test_gateway_net.py @@ -0,0 +1,42 @@ +"""Unit: shared-gateway source-IP allocation (PRD 0070).""" + +from __future__ import annotations + +import unittest + +from bot_bottle.backend.docker.gateway_net import NoFreeAddressError, next_free_ip + + +class TestNextFreeIp(unittest.TestCase): + def test_skips_router_and_returns_first_host(self) -> None: + # .1 is docker's router; the first assignable address is .2. + self.assertEqual("172.18.0.2", next_free_ip("172.18.0.0/16", [])) + + def test_skips_taken_addresses(self) -> None: + # Gateway container holds .2, a live bottle holds .3 -> next is .4. + self.assertEqual( + "172.18.0.4", next_free_ip("172.18.0.0/16", ["172.18.0.2", "172.18.0.3"]), + ) + + def test_taken_order_does_not_matter(self) -> None: + self.assertEqual( + "172.18.0.2", next_free_ip("172.18.0.0/16", ["172.18.0.3", "172.18.0.5"]), + ) + + def test_allocation_is_deterministic(self) -> None: + taken = ["172.18.0.2"] + self.assertEqual(next_free_ip("172.18.0.0/16", taken), + next_free_ip("172.18.0.0/16", taken)) + + def test_raises_when_subnet_exhausted(self) -> None: + # /30: hosts are .1 (router, reserved) and .2; taking .2 leaves none. + with self.assertRaises(NoFreeAddressError): + next_free_ip("10.9.9.0/30", ["10.9.9.2"]) + + def test_accepts_host_bits_set_cidr(self) -> None: + # A container's inspected address arrives as e.g. 172.18.0.2/16. + self.assertEqual("172.18.0.2", next_free_ip("172.18.0.5/16", [])) + + +if __name__ == "__main__": + unittest.main()