mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 04:06:42 +00:00
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:
1 parent
6eb9a59d14
commit
96df7ea0eb
10 files changed
+265
-29
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 📄
|
||||
|
||||
@@ -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 }'
|
||||
@@ -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()
|
||||
Vendored
+2
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user