mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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:
1 parent
6405952320
commit
d2b33a3b2a
8 files changed
+188
-2
No files matched your search
@@ -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}
|
||||
@@ -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()
|
||||
Vendored
+6
-2
@@ -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
@@ -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)
|
||||
Vendored
+1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user