fix(compose): keep dynamic addresses off the stack's static IPs

compose/cache.shared.override.yml attaches every worktree stack's
infinito service to the primary's network (infinito-nexus-core_default,
172.30.0.0/24) without an ipv4_address. The default network declared
no ip_range, so Docker IPAM handed out the lowest free address and the
worktree stacks took 172.30.0.2, .3, .8, .9 and, whenever the primary
container was down, .10, the primary's INFINITO_IP4. The primary's
`make compose-up` then failed with "Address already in use", and with
it `make quality-high` on main.

The default network now declares ip_range: ${INFINITO_IP_RANGE}, the
upper half of INFINITO_SUBNET. Every static address of the stack (.1,
.4 to .6, .10, .53) sits in the lower half, so a container without a
static address can no longer take one.

INFINITO_IP_RANGE is derived from the final INFINITO_SUBNET after
custom.env is applied, because a worktree pins its subnet there and
`make dotenv-force` regenerates without it in the environment. Existing
worktrees therefore get their own slot's range without a new pin.

A lint checks that every static IP in default.env and every
172.30.${slot}.N pin in scripts/system/worktree/up.sh lies below the
pool.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-18 21:44:16 +02:00
1 parent 6405952320
commit d2b33a3b2a
8 files changed
+188 -2

No files matched your search

+1
View File
@@ -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}
+3
View File
@@ -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
@@ -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. |
@@ -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"^(?P<key>INFINITO_[A-Z0-9_]+)=172\.30\.\$\{slot\}\.(?P<host>\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.<slot>.128/25: {offenders}",
)
if __name__ == "__main__":
unittest.main()
@@ -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()
+6 -2
View File
@@ -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
+40
View File
@@ -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)
+1
View File
@@ -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",