diff --git a/default.env b/default.env index 8c358423b6..adacdd160e 100644 --- a/default.env +++ b/default.env @@ -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 .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 diff --git a/docs/contributing/environment/setup.md b/docs/contributing/environment/setup.md index 92bc1c12da..75fcf77439 100644 --- a/docs/contributing/environment/setup.md +++ b/docs/contributing/environment/setup.md @@ -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, `.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. diff --git a/scripts/system/network/dns/common.sh b/scripts/system/network/dns/common.sh index 9dc774a453..c2dc8e2bc3 100755 --- a/scripts/system/network/dns/common.sh +++ b/scripts/system/network/dns/common.sh @@ -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 " " 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 <"${tmp}" diff --git a/scripts/system/network/dns/setup/README.md b/scripts/system/network/dns/setup/README.md index 9248f6178e..b194ac302b 100644 --- a/scripts/system/network/dns/setup/README.md +++ b/scripts/system/network/dns/setup/README.md @@ -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 📄 diff --git a/scripts/system/network/dns/setup/linux.sh b/scripts/system/network/dns/setup/linux.sh index 133b1feeda..81f0e0c3aa 100755 --- a/scripts/system/network/dns/setup/linux.sh +++ b/scripts/system/network/dns/setup/linux.sh @@ -65,10 +65,7 @@ EOF echo ">>> Writing dnsmasq snippet for NetworkManager: ${DNS_NM_DNSMASQ_CONF}" sudo mkdir -p "${DNS_NM_DNSMASQ_DIR}" - cat </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 </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 }' diff --git a/scripts/system/worktree/up.sh b/scripts/system/worktree/up.sh index f97a72aab2..0ac71dbd45 100755 --- a/scripts/system/worktree/up.sh +++ b/scripts/system/worktree/up.sh @@ -107,10 +107,12 @@ Worktree ready. bind IP 127.0.0.$((slot + 1)) container infinito_nexus_${slug//[.-]/_} cache net ${cache_network:-} + 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} diff --git a/tests/unit/python/utils/cleanup/test_nginx_vhosts.py b/tests/unit/python/utils/cleanup/test_nginx_vhosts.py index 4310d469a5..3f59fb967c 100644 --- a/tests/unit/python/utils/cleanup/test_nginx_vhosts.py +++ b/tests/unit/python/utils/cleanup/test_nginx_vhosts.py @@ -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 diff --git a/tests/unit/python/utils/env/handlers/infinito/test_domain.py b/tests/unit/python/utils/env/handlers/infinito/test_domain.py new file mode 100644 index 0000000000..ef80af05c4 --- /dev/null +++ b/tests/unit/python/utils/env/handlers/infinito/test_domain.py @@ -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() diff --git a/utils/env/handlers/__init__.py b/utils/env/handlers/__init__.py index aa3d74b565..adc237af9c 100644 --- a/utils/env/handlers/__init__.py +++ b/utils/env/handlers/__init__.py @@ -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, diff --git a/utils/env/handlers/infinito/domain.py b/utils/env/handlers/infinito/domain.py new file mode 100644 index 0000000000..b6eb58bc06 --- /dev/null +++ b/utils/env/handlers/infinito/domain.py @@ -0,0 +1,69 @@ +"""INFINITO_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: .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)