diff --git a/compose.yml b/compose.yml index 8d12571046..910f176e03 100644 --- a/compose.yml +++ b/compose.yml @@ -132,4 +132,5 @@ networks: ipam: config: - subnet: ${INFINITO_SUBNET:?Run 'make dotenv' to generate the .env single source of truth} + ip_range: ${INFINITO_IP_RANGE:?Run 'make dotenv' to generate the .env single source of truth} gateway: ${INFINITO_GATEWAY:?Run 'make dotenv' to generate the .env single source of truth} diff --git a/default.env b/default.env index 4697e8d1f3..8a71ccf85e 100644 --- a/default.env +++ b/default.env @@ -229,6 +229,9 @@ INFINITO_IP4=172.30.0.10 # IPv6 loopback / public address the playbook's `internet` network defaults to when no host_var pin is set. INFINITO_IP6=::1 +# Dynamic IPAM pool: upper half of INFINITO_SUBNET, derived after custom.env; every static IP sits below it. +INFINITO_IP_RANGE=172.30.0.128/25 + # True when /proc/version identifies the host as WSL2; generator overrides via runtime detection. INFINITO_IS_WSL2=false diff --git a/docs/contributing/artefact/files/compose.yml.md b/docs/contributing/artefact/files/compose.yml.md index 0348e1982a..a55fb3f4b2 100644 --- a/docs/contributing/artefact/files/compose.yml.md +++ b/docs/contributing/artefact/files/compose.yml.md @@ -91,6 +91,7 @@ The env-var contracts each service expects strictly via `${VAR:?…}` (consumed | `INFINITO_DOMAIN` | Base domain exported into the container environment. Default in [default.env](../../../../default.env). | | `INFINITO_BIND_IP` | Host address that all published ports bind to. | | `INFINITO_SUBNET` | IPAM subnet for the default bridge network. | +| `INFINITO_IP_RANGE` | IPAM pool for containers that join the default bridge without a static address, such as worktree stacks on the primary's cache network. It is the upper half of `INFINITO_SUBNET`, derived after `custom.env` by [ip_range.py](../../../../utils/env/handlers/infinito/ip_range.py); every static address stays below it. | | `INFINITO_GATEWAY` | IPAM gateway for the default bridge network. | | `INFINITO_OUTER_NETWORK_MTU` | MTU for the bridge network. Lower when the host network is tunneled. | diff --git a/tests/lint/filesystem/env/test_static_ips_outside_ip_range.py b/tests/lint/filesystem/env/test_static_ips_outside_ip_range.py new file mode 100644 index 0000000000..953850df51 --- /dev/null +++ b/tests/lint/filesystem/env/test_static_ips_outside_ip_range.py @@ -0,0 +1,74 @@ +"""Lint: no static IP of the compose stack lies in its dynamic IPAM pool. + +Containers that join the bridge without an ``ipv4_address`` take the lowest +free address of ``INFINITO_IP_RANGE``. A static address inside that pool can be +taken by such a container first, and the stack that owns it then fails with +"Address already in use". The pool is the upper half of ``INFINITO_SUBNET``, so +every static address of the primary (``default.env``) and every worktree pin +(``scripts/system/worktree/up.sh``) must stay in the lower half. +""" + +from __future__ import annotations + +import ipaddress +import re +import unittest + +from utils.cache.files import read_text +from utils.env.handlers.infinito.ip_range import upper_half +from utils.env.parser import parse_static_env + +from . import PROJECT_ROOT + +_WORKTREE_PIN_RE = re.compile( + r"^(?PINFINITO_[A-Z0-9_]+)=172\.30\.\$\{slot\}\.(?P\d+)$", + re.MULTILINE, +) + + +class TestStaticIpsOutsideIpRange(unittest.TestCase): + def test_the_primary_statics_sit_below_the_pool(self) -> None: + static = parse_static_env(PROJECT_ROOT / "default.env") + subnet = ipaddress.ip_network(static["INFINITO_SUBNET"]) + pool = ipaddress.ip_network(upper_half(static["INFINITO_SUBNET"])) + self.assertEqual( + static["INFINITO_IP_RANGE"], + str(pool), + "default.env must declare INFINITO_IP_RANGE as the upper half of " + "INFINITO_SUBNET, the value utils/env/handlers/infinito/ip_range.py derives.", + ) + + statics = {} + for key, value in static.items(): + try: + address = ipaddress.ip_address(value) + except ValueError: + continue + if address in subnet: + statics[key] = address + self.assertIn("INFINITO_IP4", statics) + offenders = sorted(key for key, address in statics.items() if address in pool) + self.assertEqual( + offenders, + [], + f"static IPs inside the dynamic pool {pool}: {offenders}", + ) + + def test_the_worktree_pins_sit_below_the_pool(self) -> None: + pins = { + match.group("key"): int(match.group("host")) + for match in _WORKTREE_PIN_RE.finditer( + read_text(str(PROJECT_ROOT / "scripts/system/worktree/up.sh")) + ) + } + self.assertIn("INFINITO_IP4", pins) + offenders = sorted(key for key, host in pins.items() if host >= 128) + self.assertEqual( + offenders, + [], + f"worktree pins inside the dynamic pool 172.30..128/25: {offenders}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/utils/env/handlers/infinito/test_ip_range.py b/tests/unit/python/utils/env/handlers/infinito/test_ip_range.py new file mode 100644 index 0000000000..2b2a6c3d99 --- /dev/null +++ b/tests/unit/python/utils/env/handlers/infinito/test_ip_range.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from utils.env.builder import EnvBuilder, build_env +from utils.env.handlers.infinito import ip_range as handler + + +class TestUpperHalf(unittest.TestCase): + def test_primary_subnet(self) -> None: + self.assertEqual(handler.upper_half("172.30.0.0/24"), "172.30.0.128/25") + + def test_worktree_subnet(self) -> None: + self.assertEqual(handler.upper_half("172.30.52.0/24"), "172.30.52.128/25") + + def test_a_host_address_is_not_a_subnet(self) -> None: + with self.assertRaises(ValueError): + handler.upper_half("172.30.0.10/24") + + +class TestFinalize(unittest.TestCase): + def test_follows_the_subnet_in_the_builder(self) -> None: + eb = EnvBuilder() + eb.set(handler.SUBNET_KEY, "172.30.7.0/24") + handler.finalize(eb) + self.assertEqual(eb.values[handler.KEY], "172.30.7.128/25") + + def test_no_subnet_sets_no_pool(self) -> None: + eb = EnvBuilder() + with patch.dict(os.environ, {handler.SUBNET_KEY: ""}): + handler.finalize(eb) + self.assertNotIn(handler.KEY, eb.values) + + def test_build_env_derives_it_from_the_subnet_custom_env_pins(self) -> None: + clean = {k: v for k, v in os.environ.items() if not k.startswith("INFINITO_")} + with tempfile.TemporaryDirectory() as tmp: + repo_root = Path(tmp) + (repo_root / "custom.env").write_text( + "INFINITO_SUBNET=172.30.9.0/24\n", encoding="utf-8" + ) + with ( + patch.dict(os.environ, clean, clear=True), + patch("utils.env.builder.detect_gha_act", return_value=(False, False)), + patch( + "utils.env.handlers.infinito.inventory.run_helper", + return_value="", + ), + ): + eb = build_env( + {"INFINITO_DISTRO": "debian", handler.SUBNET_KEY: "172.30.0.0/24"}, + repo_root, + ) + self.assertEqual(eb.values[handler.SUBNET_KEY], "172.30.9.0/24") + self.assertEqual(eb.values[handler.KEY], "172.30.9.128/25") + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/env/builder.py b/utils/env/builder.py index e44faaa474..620f3f886a 100644 --- a/utils/env/builder.py +++ b/utils/env/builder.py @@ -13,6 +13,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from utils.env.handlers import ORDERED_HANDLERS +from utils.env.handlers.infinito import ip_range from utils.env.runtime import detect_gha_act if TYPE_CHECKING: @@ -94,8 +95,10 @@ def build_env( ``comments`` is the parsed per-key comment map from default.env (see :func:`utils.env.parser.parse_static_env_with_comments`). Dynamic keys carry their comments via each handler's ``COMMENT`` - constant. ``custom.env`` overrides run last (see - :func:`apply_custom_env`). + constant. ``custom.env`` overrides run after every handler (see + :func:`apply_custom_env`); only ``INFINITO_IP_RANGE`` is derived after + them, because it follows the final ``INFINITO_SUBNET`` a worktree pins + there. """ on_gha, on_act = detect_gha_act() ctx = BuildContext( @@ -109,4 +112,5 @@ def build_env( for handler in ORDERED_HANDLERS: handler.apply(eb, ctx) apply_custom_env(eb, repo_root) + ip_range.finalize(eb) return eb diff --git a/utils/env/handlers/infinito/ip_range.py b/utils/env/handlers/infinito/ip_range.py new file mode 100644 index 0000000000..1c02ebaf48 --- /dev/null +++ b/utils/env/handlers/infinito/ip_range.py @@ -0,0 +1,40 @@ +"""INFINITO_IP_RANGE: the dynamic IPAM pool of the compose stack's bridge. + +Docker hands every container that joins the bridge without an +``ipv4_address`` the lowest free address of the pool. Worktree stacks join the +primary's network as the cache network, so without a pool they took the +primary's static addresses below .128. The pool is the upper half of the final +``INFINITO_SUBNET``; every static address sits in the lower half. +""" + +from __future__ import annotations + +import ipaddress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from utils.env.builder import EnvBuilder + +KEY = "INFINITO_IP_RANGE" +SUBNET_KEY = "INFINITO_SUBNET" +COMMENT = "Dynamic IPAM pool of INFINITO_SUBNET (its upper half); every static IP of the stack sits below it." + + +def upper_half(subnet: str) -> str: + """Return the upper half of ``subnet``. + + Args: + subnet: an IPv4 CIDR such as ``172.30.0.0/24``. + + Returns: + The CIDR of its upper half, e.g. ``172.30.0.128/25``. + """ + network = ipaddress.ip_network(subnet, strict=True) + return str(list(network.subnets(prefixlen_diff=1))[1]) + + +def finalize(eb: EnvBuilder) -> None: + subnet = eb.get(SUBNET_KEY) + if not subnet: + return + eb.set(KEY, upper_half(subnet), comment=COMMENT) diff --git a/utils/env/handlers/passthrough.py b/utils/env/handlers/passthrough.py index 5fa78aeaa9..5f6bf6b194 100644 --- a/utils/env/handlers/passthrough.py +++ b/utils/env/handlers/passthrough.py @@ -37,6 +37,7 @@ STATIC_KEYS = ( "INFINITO_DOCKER_MOUNT", "INFINITO_IP4", "INFINITO_IP6", + "INFINITO_IP_RANGE", "INFINITO_PARENT_IMAGE_OWNER", "INFINITO_PARENT_IMAGE_TAG", "INFINITO_PARENT_IMAGE_VARIANT",