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()