feat(env): give every checkout its own stack domain

Every stack answered to *.infinito.test, and the host resolver pointed that
zone at 127.0.0.1, so a worktree's stack on its own bind IP could not be opened
in a browser while the primary stack existed.

INFINITO_DOMAIN is now derived per checkout: main.infinito.test for the
primary one, and for a linked worktree its branch, lowercased with everything
outside a-z0-9_- turned into "-", below INFINITO_DNS_DOMAIN
(feature/store-i18n -> feature-store-i18n.infinito.test). The branch is read
from the worktree's gitdir HEAD instead of running git, because git refuses the
mounted checkout inside the stack container. GitHub and act runs keep the
static default, custom.env still wins.

The handler overrides the caller's value on purpose: inside the stack
container that value is the previous .env, which make setup rewrote on every
deploy, so the old domain kept feeding itself back into the next compose up.

make network-dns-setup routes each stack's domain to the INFINITO_BIND_IP in
that checkout's .env, with a local= line so AAAA answers NODATA instead of the
primary stack's ::1. It skips domains outside the base zone, which a stale
.env still carries, and fails when two checkouts claim one domain.
worktree-up prints the domain and the follow-up command.

Existing checkouts switch after make dotenv-force and a redeploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-21 17:44:38 +02:00
1 parent 6eb9a59d14
commit 96df7ea0eb
10 files changed
+265 -29

No files matched your search

+2 -2
View File
@@ -193,8 +193,8 @@ INFINITO_DOCKER_MOUNT=/var/lib/docker
# Docker volume backing /var/lib/docker; generator overrides to /mnt/docker on GHA.
INFINITO_DOCKER_VOLUME=docker
# Top-level project domain rendered into vhost/cert/test fixtures; CI/dev default, overridden in real deploys.
INFINITO_DOMAIN=infinito.test
# Stack domain rendered into vhosts, certs and test fixtures; a worktree gets <branch>.INFINITO_DNS_DOMAIN instead.
INFINITO_DOMAIN=main.infinito.test
# Repo-relative path of the filesystem userland install script (ext4, btrfs, zfs).
INFINITO_FILESYSTEM_INSTALL_SCRIPT=scripts/install/filesystem.sh
+1
View File
@@ -38,6 +38,7 @@ make network-trust-ca
| Host preparation | `make environment-bootstrap` | Prepares the local development machine for the project workflow. |
| Start stack | `make compose-up` | Starts the local development stack. |
| Browser trust | `make network-trust-ca` | Trusts the generated local [CA](https://en.wikipedia.org/wiki/Certificate_authority) so `*.infinito.test` works correctly in your browser. |
| Stack domains | `make network-dns-setup` | Routes each stack's domain to its bind IP: `main.infinito.test` for the primary checkout, `<branch>.infinito.test` for every worktree. Re-run it after `make worktree-up` or `make worktree-down`. |
The `pre-commit` hooks are installed automatically by `make environment-bootstrap`.
Per-machine overrides go into the process environment before invoking `make` (e.g. `export INFINITO_FOO=…`); `make dotenv` honors caller-set values via setdefault semantics.
+66 -16
View File
@@ -7,7 +7,6 @@ DNS_HOSTS_FILE="${INFINITO_DNS_HOSTS_FILE}"
DNS_HOSTS_BLOCK_BEGIN="# BEGIN infinito-dns-fallback"
DNS_HOSTS_BLOCK_END="# END infinito-dns-fallback"
DNS_HOSTS_GENERATOR="${DNS_PROJECT_ROOT}/cli/meta/domains/__main__.py"
DNS_HOSTS_FALLBACK_DEFAULT_RAW="${DNS_DOMAIN} dashboard.${DNS_DOMAIN} matomo.${DNS_DOMAIN}"
# shellcheck disable=SC2034
DNS_NM_CONF="/etc/NetworkManager/conf.d/00-infinito-dnsmasq.conf"
@@ -61,14 +60,14 @@ dns_read_hosts_fallback_entries_from_raw() {
}
dns_read_generated_hosts_fallback_entries() {
local output
local domain="$1" output
if ! command -v python3 >/dev/null 2>&1 || [[ ! -f "${DNS_HOSTS_GENERATOR}" ]]; then
return 1
fi
if ! output="$(
DOMAIN="${DNS_DOMAIN}" PYTHONPATH="${DNS_PROJECT_ROOT}" python3 -m cli.meta.domains --domain-primary "${DNS_DOMAIN}" --alias --www
DOMAIN="${domain}" PYTHONPATH="${DNS_PROJECT_ROOT}" python3 -m cli.meta.domains --domain-primary "${domain}" --alias --www
)"; then
echo ">>> Failed to generate DNS hosts from role configs; using static fallback." >&2
return 1
@@ -82,17 +81,70 @@ dns_read_generated_hosts_fallback_entries() {
printf '%s\n' "${output}" | dns_normalize_hosts_entries
}
dns_env_value() {
awk -v key="$2" 'index($0, key "=") == 1 { value = substr($0, length(key) + 2) } END { gsub(/"/, "", value); print value }' "$1"
}
dns_checkout_stacks() {
local listing path domain ip
if ! listing="$(git -C "${DNS_PROJECT_ROOT}" worktree list --porcelain 2>/dev/null)"; then
listing="worktree ${DNS_PROJECT_ROOT}"
fi
while IFS= read -r path; do
if [[ ! -f "${path}/.env" ]]; then
continue
fi
domain="$(dns_env_value "${path}/.env" INFINITO_DOMAIN)"
ip="$(dns_env_value "${path}/.env" INFINITO_BIND_IP)"
if [[ "${domain}" == *".${DNS_DOMAIN}" && -n "${ip}" ]]; then
printf '%s %s %s\n' "${domain}" "${ip}" "${path}"
fi
done < <(printf '%s\n' "${listing}" | awk 'index($0, "worktree ") == 1 { print substr($0, 10) }')
}
# Param: none. Output: one "<INFINITO_DOMAIN> <INFINITO_BIND_IP>" line per checkout whose .env places its stack below INFINITO_DNS_DOMAIN; exits 2 when two checkouts claim one domain.
dns_stacks() {
dns_checkout_stacks | awk '
$1 in ip && ip[$1] != $2 {
printf "ERROR: %s is claimed by %s and %s\n", $1, path[$1], $3 > "/dev/stderr"
exit 2
}
!($1 in ip) { ip[$1] = $2; path[$1] = $3; print $1, $2 }
'
}
dns_stack_dnsmasq_lines() {
dns_stacks | awk '{ printf "address=/%s/%s\nlocal=/%s/\n", $1, $2, $1 }'
}
dns_dnsmasq_config() {
cat <<EOF
address=/${DNS_DOMAIN}/127.0.0.1
address=/${DNS_DOMAIN}/::1
EOF
dns_stack_dnsmasq_lines
}
dns_hosts_fallback_lines() {
local domain ip
if [[ -n "${INFINITO_DNS_HOSTS:-}" ]]; then # nocheck: infinito-dns-hosts-optional
dns_read_hosts_fallback_entries_from_raw "${INFINITO_DNS_HOSTS}" | sed 's/^/127.0.0.1 /' # nocheck: infinito-dns-hosts-optional
return
fi
dns_stacks | while read -r domain ip; do
{
dns_read_generated_hosts_fallback_entries "${domain}" ||
dns_read_hosts_fallback_entries_from_raw "${domain} dashboard.${domain} matomo.${domain}"
} | sed "s/^/${ip} /"
done
}
dns_read_hosts_fallback_entries() {
if [[ -n "${INFINITO_DNS_HOSTS:-}" ]]; then # nocheck: infinito-dns-hosts-optional
dns_read_hosts_fallback_entries_from_raw "${INFINITO_DNS_HOSTS}" # nocheck: infinito-dns-hosts-optional
return
fi
if dns_read_generated_hosts_fallback_entries; then
return
fi
dns_read_hosts_fallback_entries_from_raw "${DNS_HOSTS_FALLBACK_DEFAULT_RAW}"
dns_hosts_fallback_lines | awk '{ print $2 }'
}
dns_rewrite_hosts_file() {
@@ -132,9 +184,7 @@ dns_write_hosts_fallback() {
printf '\n'
fi
printf '%s\n' "${DNS_HOSTS_BLOCK_BEGIN}"
while IFS= read -r host; do
printf '127.0.0.1 %s\n' "${host}"
done < <(dns_read_hosts_fallback_entries)
dns_hosts_fallback_lines
printf '%s\n' "${DNS_HOSTS_BLOCK_END}"
} >"${tmp}"
@@ -4,6 +4,9 @@ This directory contains platform-specific scripts for setting up local DNS resol
All scripts map `*.infinito.test` → `127.0.0.1` so that local deployments are reachable via their expected hostnames.
Every stack has its own domain below it (`INFINITO_DOMAIN`): the primary checkout is `main.infinito.test`, a worktree from `make worktree-up` is its branch lowercased with every character outside `a-z0-9_-` replaced by `-` (`feature/store-i18n` → `feature-store-i18n.infinito.test`).
`linux.sh` routes each stack's domain to that checkout's `INFINITO_BIND_IP`, read from the `.env` of every `git worktree list` entry. Re-run `make network-dns-setup` after creating or removing a worktree.
---
## Scripts 📄
+4 -10
View File
@@ -65,10 +65,7 @@ EOF
echo ">>> Writing dnsmasq snippet for NetworkManager: ${DNS_NM_DNSMASQ_CONF}"
sudo mkdir -p "${DNS_NM_DNSMASQ_DIR}"
cat <<EOF | sudo tee "${DNS_NM_DNSMASQ_CONF}" >/dev/null
address=/${DNS_DOMAIN}/127.0.0.1
address=/${DNS_DOMAIN}/::1
EOF
dns_dnsmasq_config | sudo tee "${DNS_NM_DNSMASQ_CONF}" >/dev/null
echo ">>> Restarting NetworkManager"
sudo systemctl restart NetworkManager
@@ -83,10 +80,7 @@ configure_via_system_dnsmasq() {
echo ">>> Writing system dnsmasq config: ${DNS_SYS_DNSMASQ_CONF}"
sudo mkdir -p /etc/dnsmasq.d
cat <<EOF | sudo tee "${DNS_SYS_DNSMASQ_CONF}" >/dev/null
address=/${DNS_DOMAIN}/127.0.0.1
address=/${DNS_DOMAIN}/::1
EOF
dns_dnsmasq_config | sudo tee "${DNS_SYS_DNSMASQ_CONF}" >/dev/null
echo ">>> Enabling and restarting dnsmasq service"
sudo systemctl enable dnsmasq --now
@@ -112,5 +106,5 @@ dns_test_resolution
echo
echo ">>> Local DNS configured for:"
echo " ${DNS_DOMAIN}"
echo " *.${DNS_DOMAIN}"
echo " *.${DNS_DOMAIN} -> 127.0.0.1"
dns_stacks | awk '{ printf " *.%s -> %s\n", $1, $2 }'
+2
View File
@@ -107,10 +107,12 @@ Worktree ready.
bind IP 127.0.0.$((slot + 1))
container infinito_nexus_${slug//[.-]/_}
cache net ${cache_network:-<none>}
domain $(awk -F= '$1 == "INFINITO_DOMAIN" { print $2 }' "${path}/.env")
Next:
cd ${path}
make compose-up
make network-dns-setup # routes the domain above to the bind IP
Release with:
make worktree-down branch=${branch}
@@ -18,6 +18,7 @@ from utils.cleanup.nginx_vhosts import (
main,
purge_vhost_files_for_entities,
)
from utils.domains.default_primary import default_domain_primary
from utils.roles.categories import categories_file
from utils.roles.mapping import ROLE_FILE_META_DOMAINS, ROLE_FILE_VARS_MAIN
@@ -365,7 +366,9 @@ class TestResolveDomainPrimary(unittest.TestCase):
if k not in ("DOMAIN", "INFINITO_DOMAIN")
}
with patch.dict(os.environ, env, clear=True):
self.assertEqual(mod._resolve_domain_primary(None), "infinito.test")
self.assertEqual(
mod._resolve_domain_primary(None), default_domain_primary()
)
if __name__ == "__main__": # pragma: no cover
@@ -0,0 +1,112 @@
"""Unit tests for :mod:`utils.env.handlers.infinito.domain`."""
from __future__ import annotations
import io
import os
import subprocess
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from utils.env.builder import BuildContext, EnvBuilder
from utils.env.handlers.infinito import domain as handler
_GIT = [
"git",
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgSign=false",
]
def _git(repo: Path, *args: str) -> None:
subprocess.run([*_GIT, "-C", str(repo), *args], check=True, capture_output=True)
def _repository(root: Path) -> Path:
repo = root / "repo"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
_git(repo, "commit", "-q", "--allow-empty", "-m", "init")
return repo
def _worktree(repo: Path, branch: str) -> Path:
path = repo.parent / "worktree"
_git(repo, "worktree", "add", "-q", "-b", branch, str(path))
return path
def _apply(
repo_root: Path, *, on_gha: bool = False, on_act: bool = False
) -> str | None:
ctx = BuildContext(
static={},
static_comments={},
repo_root=repo_root,
on_gha=on_gha,
on_act=on_act,
)
eb = EnvBuilder()
eb.set("INFINITO_DNS_DOMAIN", "infinito.test")
handler.apply(eb, ctx)
return eb.values.get(handler.KEY)
class TestDomain(unittest.TestCase):
def test_primary_checkout_is_main(self) -> None:
with TemporaryDirectory() as td:
self.assertEqual(_apply(_repository(Path(td))), "main.infinito.test")
def test_directory_without_git_is_main(self) -> None:
with TemporaryDirectory() as td:
self.assertEqual(_apply(Path(td)), "main.infinito.test")
def test_worktree_is_its_normalised_branch(self) -> None:
with TemporaryDirectory() as td:
worktree = _worktree(_repository(Path(td)), "Feature/Store_I18n.v2")
self.assertEqual(_apply(worktree), "feature-store_i18n-v2.infinito.test")
def test_worktree_with_unreachable_git_directory_keeps_the_default(
self,
) -> None:
with TemporaryDirectory() as td:
checkout = Path(td)
(checkout / ".git").write_text(
"gitdir: /host/only/.git/worktrees/x\n", encoding="utf-8"
)
stderr = io.StringIO()
with redirect_stderr(stderr):
self.assertIsNone(_apply(checkout))
self.assertIn("not reachable", stderr.getvalue())
def test_branch_too_long_for_a_dns_label_fails(self) -> None:
with TemporaryDirectory() as td:
worktree = _worktree(_repository(Path(td)), "a" * 64)
with self.assertRaises(SystemExit):
_apply(worktree)
def test_stale_caller_value_is_replaced_but_ci_runs_keep_the_default(
self,
) -> None:
with TemporaryDirectory() as td:
worktree = _worktree(_repository(Path(td)), "feature/x")
with patch.dict(os.environ, {handler.KEY: "infinito.test"}):
self.assertEqual(_apply(worktree), "feature-x.infinito.test")
self.assertIsNone(_apply(worktree, on_gha=True))
self.assertIsNone(_apply(worktree, on_act=True))
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -20,6 +20,7 @@ from .infinito import (
container,
distros,
docker_volume,
domain,
image,
image_repository,
inventory,
@@ -70,6 +71,7 @@ ORDERED_HANDLERS = [
worker_cpu,
worker_fetch,
container,
domain,
running_on_act,
running_on_github,
cache_stack,
+69
View File
@@ -0,0 +1,69 @@
"""INFINITO_DOMAIN: ``<stack>.<INFINITO_DNS_DOMAIN>``, where the stack is
``main`` for the primary checkout and the normalised branch for a linked git
worktree. Always overrides the caller env, which inside the stack container is
the previous ``.env``; GitHub/act runs keep the static default and custom.env
pins win."""
from __future__ import annotations
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from utils.cache.files import read_text
if TYPE_CHECKING:
from utils.env.builder import BuildContext, EnvBuilder
KEY = "INFINITO_DOMAIN"
COMMENT = "Domain of this stack: <main|worktree branch>.INFINITO_DNS_DOMAIN."
PRIMARY_LABEL = "main"
MAX_LABEL_LENGTH = 63
_BRANCH_REF = "ref: refs/heads/"
def stack_label(repo_root: Path) -> str | None:
"""Return the DNS label of the stack that runs from ``repo_root``.
Args:
repo_root: the checkout the ``.env`` is generated for.
Returns:
``main`` unless ``repo_root`` is a linked git worktree, whose branch is
lowercased and every character outside ``[a-z0-9_-]`` replaced by
``-``; ``None`` when the worktree's git directory is not reachable,
as inside a container that mounts only the checkout.
"""
marker = repo_root / ".git"
if not marker.is_file():
return PRIMARY_LABEL
gitdir = Path(read_text(str(marker)).strip().removeprefix("gitdir:").strip())
head = (gitdir if gitdir.is_absolute() else marker.parent / gitdir) / "HEAD"
if not head.is_file():
return None
branch = read_text(str(head)).strip().removeprefix(_BRANCH_REF)
label = re.sub(r"[^a-z0-9_-]", "-", branch.lower()).strip("-")
if not label or len(label) > MAX_LABEL_LENGTH:
print(
f"ERROR: worktree branch {branch!r} yields no DNS label of 1 to "
f"{MAX_LABEL_LENGTH} characters (got {label!r})",
file=sys.stderr,
)
sys.exit(2)
return label
def apply(eb: EnvBuilder, ctx: BuildContext) -> None:
if ctx.on_gha or ctx.on_act:
return
label = stack_label(ctx.repo_root)
if label is None:
print(
f"WARNING: {ctx.repo_root} is a git worktree whose git directory is "
f"not reachable here; {KEY} keeps {eb.get(KEY)!r} unless custom.env "
"pins it",
file=sys.stderr,
)
return
eb.set(KEY, f"{label}.{eb.get('INFINITO_DNS_DOMAIN')}", comment=COMMENT)