Files
computer-playbook/utils/env/builder.py
T
kevinveenbirkenbachandClaude Opus 5.5 d2b33a3b2a 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>
2026-10-18 21:44:16 +02:00

117 lines
3.7 KiB
Python

"""Compose the `.env` value-set from static defaults plus runtime context.
Thin orchestrator: each variable's mapping/computation lives in its own
module under :mod:`utils.env.handlers`. ``build_env`` walks the
``ORDERED_HANDLERS`` registry, letting each handler read/write through
an :class:`EnvBuilder`.
"""
from __future__ import annotations
import os
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:
from pathlib import Path
@dataclass(frozen=True)
class BuildContext:
"""Per-build inputs passed to every handler.
Attributes are read-only; per-handler scratch state lives on the
:class:`EnvBuilder` instead.
"""
static: dict[str, str]
static_comments: dict[str, str]
repo_root: Path
on_gha: bool
on_act: bool
class EnvBuilder:
"""Mirror bash ``: "${KEY:=value}"`` semantics with per-key comments.
``setdefault`` falls back to the supplied value only when the
process env has no non-empty value for the key. ``set`` is
unconditional. ``get`` reads from the builder, falling back to the
process env so derived values (e.g. ``INFINITO_CONTAINER``) can be
composed off previously-set keys.
"""
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.comments: dict[str, str] = {}
def setdefault(self, key: str, value: str, *, comment: str = "") -> str:
existing = os.environ.get(key, "").strip()
self.values[key] = existing or value
if comment:
self.comments.setdefault(key, comment)
return self.values[key]
def set(self, key: str, value: str, *, comment: str = "") -> str:
self.values[key] = value
if comment:
self.comments.setdefault(key, comment)
return value
def get(self, key: str) -> str:
return self.values.get(key, os.environ.get(key, ""))
def apply_custom_env(eb: EnvBuilder, repo_root: Path) -> None:
"""Overwrite every key found in ``<repo_root>/custom.env``.
``custom.env`` is the operator's untracked override file; its values
win over static defaults AND handler-computed values.
"""
from utils.env.parser import parse_static_env_with_comments
custom_path = repo_root / "custom.env"
if not custom_path.is_file():
return
custom, custom_comments = parse_static_env_with_comments(custom_path)
for key, value in custom.items():
eb.set(key, value)
eb.comments[key] = custom_comments.get(key) or eb.comments.get(key, "")
def build_env(
static: dict[str, str],
repo_root: Path,
*,
comments: dict[str, str] | None = None,
) -> EnvBuilder:
"""Compose the full env-set from ``static`` (parsed default.env) +
runtime context resolved against ``repo_root``.
``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 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(
static=static,
static_comments=comments or {},
repo_root=repo_root,
on_gha=on_gha,
on_act=on_act,
)
eb = EnvBuilder()
for handler in ORDERED_HANDLERS:
handler.apply(eb, ctx)
apply_custom_env(eb, repo_root)
ip_range.finalize(eb)
return eb