From 8aba198aecfb5ebdd6799c6c0fc680379e6ce0e4 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Tue, 28 Jul 2026 16:15:17 +0200 Subject: [PATCH] feat(worktree): isolated per-branch checkouts sharing one cache stack Parallel checkouts collided on the compose subnet, host ports and container names, so a quality run in a worktree died at compose-up. Slot each checkout via custom.env and layer two overrides on top: the shared cache network in place of a second cache stack, and the shared .git directory without which git aborts inside the container. Both overlays gate on the resource they need, not on the slot number, so a stray INFINITO_INSTANCE cannot make compose demand a value nobody set. Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 20 ++++ aliases | 2 + .../deploy/development/compose.py | 5 +- cli/administration/deploy/development/env.py | 15 ++- .../deploy/development/profile.py | 22 ++++ compose/cache.shared.override.yml | 18 ++++ compose/worktree.override.yml | 12 +++ default.env | 9 ++ scripts/system/worktree/down.sh | 51 +++++++++ scripts/system/worktree/lib.sh | 68 ++++++++++++ scripts/system/worktree/up.sh | 102 ++++++++++++++++++ .../meta/env/test_dotenv_generator.py | 6 ++ .../deploy/development/test_compose.py | 19 ++-- .../development/test_env_compose_file_args.py | 102 ++++++++++++++++++ .../deploy/development/test_profile.py | 44 +++++++- utils/env/handlers/passthrough.py | 3 + 16 files changed, 480 insertions(+), 18 deletions(-) create mode 100644 compose/cache.shared.override.yml create mode 100644 compose/worktree.override.yml create mode 100755 scripts/system/worktree/down.sh create mode 100755 scripts/system/worktree/lib.sh create mode 100755 scripts/system/worktree/up.sh create mode 100644 tests/unit/cli/administration/deploy/development/test_env_compose_file_args.py diff --git a/Makefile b/Makefile index 5eaa8c31b3..e42871cec3 100644 --- a/Makefile +++ b/Makefile @@ -767,6 +767,26 @@ test-unit: install INFINITO_COMPILE=0 \ bash scripts/tests/code/wrapper.sh +.PHONY: worktree-down +# Stop a branch worktree's stack, release the checkout and free its slot. +# Usage: make worktree-down branch= [base=] [force=true] +# Param branch: branch the worktree was created for (required). +# Param base: parent directory the worktree lives in (default /tmp). +# Param force: true discards uncommitted changes in the worktree. +worktree-down: + @test -n '$(branch)' || { echo 'usage: make worktree-down branch= [base=] [force=true]'; exit 2; } + @bash scripts/system/worktree/down.sh '$(branch)' '$(or $(base),/tmp)' '$(or $(force),false)' + +.PHONY: worktree-up +# Check a branch out into an isolated worktree with its own subnet, ports and container names. +# Usage: make worktree-up branch= [base=] +# Note: the worktree shares the primary checkout's cache stack instead of starting its own. +# Param branch: branch to check out (required). +# Param base: parent directory for the worktree (default /tmp). +worktree-up: + @test -n '$(branch)' || { echo 'usage: make worktree-up branch= [base=]'; exit 2; } + @bash scripts/system/worktree/up.sh '$(branch)' '$(or $(base),/tmp)' + .PHONY: wsl2-dns-setup # Set up DNS on WSL2. wsl2-dns-setup: diff --git a/aliases b/aliases index 508cf513ea..81888efe1f 100644 --- a/aliases +++ b/aliases @@ -135,5 +135,7 @@ alias i8tenu='m test-unit' alias i8tesi='m test-signed' alias i8texe='m test-external' alias i8wo='m wsl2-dns-setup' +alias i8wodo='m worktree-down' alias i8wose='m wsl2-systemd-check' alias i8wotu='m wsl2-trust-windows' +alias i8woup='m worktree-up' diff --git a/cli/administration/deploy/development/compose.py b/cli/administration/deploy/development/compose.py index 6a7b25e7f2..91539db02c 100644 --- a/cli/administration/deploy/development/compose.py +++ b/cli/administration/deploy/development/compose.py @@ -190,7 +190,7 @@ class Compose: args: list[str] = ["up", "-d"] if no_build: args.append("--no-build") - if self.profile.registry_cache_active(): + if self.profile.owns_cache_stack(): self._generate_package_frontend_certs(env) args += ["registry-cache", "package-cache", "package-cache-frontend"] args += ["coredns", "infinito"] @@ -199,8 +199,9 @@ class Compose: self.wait_for_healthy() - if self.profile.registry_cache_active(): + if self.profile.owns_cache_stack(): self._bootstrap_package_cache(env) + if self.profile.registry_cache_active(): self._install_package_frontend_ca_in_runner() if run_entry_init: diff --git a/cli/administration/deploy/development/env.py b/cli/administration/deploy/development/env.py index 842f6d3bba..19464f5582 100644 --- a/cli/administration/deploy/development/env.py +++ b/cli/administration/deploy/development/env.py @@ -8,12 +8,23 @@ VALID_DISTROS: tuple[str, ...] = distro_names() def compose_file_args() -> list[str]: - """Compose `-f` flags shared by up and down flows.""" + """Compose `-f` flags shared by up and down flows. + + Each override is gated on the resource it needs being present, not on the + instance slot: an override whose `:?` guard is unsatisfied would abort the + stack outright, so a checkout that was never handed a shared git dir or + cache network simply does not layer that file in. + """ from .profile import Profile + profile = Profile() out = ["-f", "compose.yml"] - if Profile().registry_cache_active(): + if profile.shared_git_dir(): + out += ["-f", "compose/worktree.override.yml"] + if profile.registry_cache_active(): out += ["-f", "compose/cache.override.yml"] + if profile.shared_cache_network(): + out += ["-f", "compose/cache.shared.override.yml"] if (os.environ.get("INFINITO_PUBLISH_PORTS") or "").strip().lower() == "false": out += ["-f", "compose/noports.override.yml"] return out diff --git a/cli/administration/deploy/development/profile.py b/cli/administration/deploy/development/profile.py index cbf803eb3a..756bddbd7e 100644 --- a/cli/administration/deploy/development/profile.py +++ b/cli/administration/deploy/development/profile.py @@ -19,3 +19,25 @@ class Profile: def registry_cache_active(self) -> bool: """True iff the cache stack should be loaded (local dev only).""" return not self.is_ci() + + def instance(self) -> int: + """Slot index of this checkout; 0 is the primary one. + + Bookkeeping only: the compose layering below keys off the concrete + resources a worktree was handed, never off this number, so a stray + slot cannot make compose demand a value nobody set. + """ + raw = (os.environ.get("INFINITO_INSTANCE") or "0").strip() + return int(raw) if raw.isdigit() else 0 + + def shared_git_dir(self) -> str: + """Path of the checkout's shared .git directory, empty when it owns one.""" + return (os.environ.get("INFINITO_GIT_COMMON_DIR") or "").strip() + + def shared_cache_network(self) -> str: + """Docker network of a cache stack owned elsewhere, empty when unset.""" + return (os.environ.get("INFINITO_CACHE_NETWORK") or "").strip() + + def owns_cache_stack(self) -> bool: + """True iff this instance runs the cache stack rather than sharing it.""" + return self.registry_cache_active() and not self.shared_cache_network() diff --git a/compose/cache.shared.override.yml b/compose/cache.shared.override.yml new file mode 100644 index 0000000000..000a6bf658 --- /dev/null +++ b/compose/cache.shared.override.yml @@ -0,0 +1,18 @@ +--- +# Layered on cache.override.yml for secondary instances, which reach the +# primary instance's cache stack instead of running one of their own. +# See docs/contributing/environment/cache.md. + +services: + infinito: + # Exception: required:false still makes compose CREATE the cache services, + # which would claim the primary instance's IPs outside this subnet. + depends_on: !reset null + networks: + cache: + +networks: + cache: + external: true + name: ${INFINITO_CACHE_NETWORK:?Set by 'make worktree-up' from the running + cache stack; empty on the primary instance} diff --git a/compose/worktree.override.yml b/compose/worktree.override.yml new file mode 100644 index 0000000000..93b5f6f48c --- /dev/null +++ b/compose/worktree.override.yml @@ -0,0 +1,12 @@ +--- +# Layered on compose.yml for secondary instances. A git worktree's .git is a +# file pointing at an absolute path inside the primary checkout, so git only +# works in the container when that directory is mounted where the pointer +# expects it. + +services: + infinito: + volumes: + - ${INFINITO_GIT_COMMON_DIR:?Set by 'make worktree-up' from git rev-parse + --git-common-dir}:${INFINITO_GIT_COMMON_DIR:?Set by 'make worktree-up' + from git rev-parse --git-common-dir} diff --git a/default.env b/default.env index 3e1a381608..b78ee55acb 100644 --- a/default.env +++ b/default.env @@ -34,6 +34,9 @@ INFINITO_BUILD=1 # `1` keeps BuildKit on (default); `0` forces the compose wrapper to use the legacy builder. INFINITO_BUILDKIT_ENABLED=1 +# External docker network carrying the shared cache stack; empty on the primary instance, which owns the stack itself. +INFINITO_CACHE_NETWORK= + # Bind-mounted apt sources.list fragment for the package-cache frontend. INFINITO_CACHE_PACKAGE_APT_LIST=./compose/package-cache/apt.list @@ -139,12 +142,18 @@ INFINITO_GATEWAY=172.30.0.1 # Prefix path under which GHCR mirror lookups search for cached images. INFINITO_GHCR_MIRROR_PREFIX=mirror +# Shared .git directory a worktree checkout points at; empty on the primary instance, which carries its own. +INFINITO_GIT_COMMON_DIR= + # Run-context flag like INFINITO_DEPLOY_MODE: the single-node workflow sets 'true' so host discovery drops compose-covered roles. INFINITO_HOST_EXCLUDE_COMPOSE= # Image tag used to address the published infinito image (CI publishes 'latest' on push). INFINITO_IMAGE_TAG=latest +# Slot index isolating a parallel checkout: 0 owns the cache stack, 1..255 shift subnet, bind IP and container names. +INFINITO_INSTANCE=0 + # Repo-relative path to the Ansible vars file `infinito administration inventory provision --vars-file <...>` consumes. INFINITO_INVENTORY_VARS_FILE=inventories/development/default.yml diff --git a/scripts/system/worktree/down.sh b/scripts/system/worktree/down.sh new file mode 100755 index 0000000000..5a12a03c0e --- /dev/null +++ b/scripts/system/worktree/down.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Tear down the worktree a branch was checked out into: stop its compose +# stack, then release the checkout and free its slot. Refuses to drop a +# worktree with uncommitted changes unless force=true is passed. +# +# Use `make worktree-down` rather than calling this script directly. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +# shellcheck source=scripts/system/worktree/lib.sh +source "${SCRIPT_DIR}/lib.sh" + +branch="${1:?branch name required}" +base="${2:-/tmp}" +force="${3:-false}" + +cd "${REPO_ROOT}" + +slug="$(worktree_slug "${branch}")" +path="$(worktree_path "${slug}" "${base}")" + +if [ ! -d "${path}" ]; then + echo "FAILURE: no worktree at ${path} for branch '${branch}'" >&2 + exit 1 +fi + +if [ "${force}" != "true" ] && [ -n "$(git -C "${path}" status --porcelain)" ]; then + echo "FAILURE: ${path} has uncommitted changes; commit them or pass force=true" >&2 + git -C "${path}" status --short >&2 + exit 1 +fi + +slot="$(worktree_slot_of "${path}")" +echo ">>> Stopping the compose stack in ${path} (slot ${slot})" +if ! make -C "${path}" compose-down; then + echo ">>> WARNING: compose-down failed; removing the worktree anyway" +fi + +echo ">>> Removing worktree ${path}" +if [ "${force}" = "true" ]; then + git worktree remove --force "${path}" +else + git worktree remove "${path}" +fi + +echo "Slot ${slot} released." diff --git a/scripts/system/worktree/lib.sh b/scripts/system/worktree/lib.sh new file mode 100755 index 0000000000..e142c23bbe --- /dev/null +++ b/scripts/system/worktree/lib.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Shared helpers for the worktree slot manager. A slot is an integer that +# isolates one checkout from every other: it shifts the compose subnet, the +# host bind IP and the swarm lab subnet, so several worktrees can run their +# stacks side by side. Slot 0 is the primary checkout and owns the shared +# cache stack; every worktree gets the next free slot and joins that cache +# over an external docker network. +# +# Volumes are deliberately not slotted: compose already namespaces them by +# project, and INFINITO_DOCKER_VOLUME names a top-level compose key, so +# renaming it would point the mount at an undeclared volume. +# +# The pins are written to custom.env AND passed to the generator in a cleared +# environment: custom.env keeps them reproducible across later `make dotenv` +# runs, while the explicit env covers branches whose generator predates +# custom.env support. Inheriting the caller's environment would leak the +# invoking worktree's own slot into the new one. +# +# Slot discovery reads custom.env first: a branch whose default.env predates +# INFINITO_INSTANCE never materialises the key into .env, which would make the +# checkout look like slot 0 and hand its number out twice. +# +# Sourced by up.sh and down.sh; not meant to be executed directly. + +WORKTREE_DIR_PREFIX="infinito-wt-" + +worktree_slug() { + printf '%s' "$1" | sed -e 's#[^a-zA-Z0-9._-]#-#g' -e 's#^[^a-zA-Z0-9]*##' -e 's#[-.]*$##' +} + +worktree_path() { + printf '%s/%s%s' "$2" "${WORKTREE_DIR_PREFIX}" "$1" +} + +worktree_slot_of() { + local slot=0 file + for file in "$1/custom.env" "$1/.env"; do + if [ -f "${file}" ]; then + slot="$(awk -F= '/^INFINITO_INSTANCE=/{print $2}' "${file}" | tail -n1)" + if [ -n "${slot}" ]; then + break + fi + fi + done + case "${slot}" in + '' | *[!0-9]*) slot=0 ;; + esac + printf '%s' "${slot}" +} + +worktree_next_slot() { + local max=0 path slot + while read -r path; do + slot="$(worktree_slot_of "${path}")" + if [ "${slot}" -gt "${max}" ]; then + max="${slot}" + fi + done < <(git worktree list --porcelain | awk '/^worktree /{print $2}') + printf '%s' "$((max + 1))" +} + +worktree_cache_network() { + docker inspect infinito-registry-cache \ + --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | + head -n1 +} diff --git a/scripts/system/worktree/up.sh b/scripts/system/worktree/up.sh new file mode 100755 index 0000000000..87f4cdf686 --- /dev/null +++ b/scripts/system/worktree/up.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Create an isolated git worktree for a branch and pin it to a free slot, so +# its compose stack no longer collides with the primary checkout on subnet, +# host ports, container names or volumes. The cache stack is not duplicated: +# the worktree joins the running one over an external network. +# +# Use `make worktree-up` rather than calling this script directly. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +# shellcheck source=scripts/system/worktree/lib.sh +source "${SCRIPT_DIR}/lib.sh" + +branch="${1:?branch name required}" +base="${2:-/tmp}" +max_slot=255 + +cd "${REPO_ROOT}" + +slug="$(worktree_slug "${branch}")" +if [ -z "${slug}" ]; then + echo "FAILURE: branch '${branch}' slugs to an empty name" >&2 + exit 1 +fi + +path="$(worktree_path "${slug}" "${base}")" +if [ -e "${path}" ]; then + echo "FAILURE: ${path} already exists; run 'make worktree-down branch=${branch}' first" >&2 + exit 1 +fi + +slot="$(worktree_next_slot)" +if [ "${slot}" -gt "${max_slot}" ]; then + echo "FAILURE: no free slot left (max ${max_slot}); tear down an existing worktree" >&2 + exit 1 +fi + +cache_network="$(worktree_cache_network)" +if [ -z "${cache_network}" ]; then + echo ">>> WARNING: no running cache stack found; the worktree will start without a shared cache" +fi + +echo ">>> Creating worktree for '${branch}' at ${path} (slot ${slot})" +git worktree add "${path}" "${branch}" + +git_common_dir="$(git rev-parse --path-format=absolute --git-common-dir)" + +echo ">>> Pinning slot ${slot} in ${path}/custom.env" +cat >"${path}/custom.env" <>> Generating .env from the pins" +env -i -C "${path}" \ + HOME="${HOME}" \ + PATH="${PATH}" \ + INFINITO_INSTANCE="${slot}" \ + INFINITO_GIT_COMMON_DIR="${git_common_dir}" \ + INFINITO_SUBNET="172.30.${slot}.0/24" \ + INFINITO_GATEWAY="172.30.${slot}.1" \ + INFINITO_DNS_IP="172.30.${slot}.53" \ + INFINITO_IP4="172.30.${slot}.10" \ + INFINITO_BIND_IP="127.0.0.$((slot + 1))" \ + INFINITO_SWARM_LAB_SUBNET="192.168.$((244 + slot)).0/24" \ + INFINITO_RUNNER_PREFIX="infinito-${slug}" \ + INFINITO_CACHE_NETWORK="${cache_network}" \ + python3 -m cli.meta.env + +cat <} + +Next: + cd ${path} + make compose-up + +Release with: + make worktree-down branch=${branch} +SUMMARY diff --git a/tests/integration/meta/env/test_dotenv_generator.py b/tests/integration/meta/env/test_dotenv_generator.py index 15535a313c..13d684bf92 100644 --- a/tests/integration/meta/env/test_dotenv_generator.py +++ b/tests/integration/meta/env/test_dotenv_generator.py @@ -188,6 +188,12 @@ class TestDotenvSmoke(unittest.TestCase): produced .env contains the expected baseline keys.""" def test_clean_run_produces_expected_keys(self) -> None: + if (PROJECT_ROOT / "custom.env").is_file(): + self.skipTest( + "custom.env overrides handler-computed values by design " + "(see utils/env/builder.py:apply_custom_env), so this checkout " + "cannot produce a clean run; 'make worktree-up' writes one." + ) with tempfile.TemporaryDirectory() as td: env = { k: v diff --git a/tests/unit/cli/administration/deploy/development/test_compose.py b/tests/unit/cli/administration/deploy/development/test_compose.py index 83c272eec9..5d6c0d1c63 100644 --- a/tests/unit/cli/administration/deploy/development/test_compose.py +++ b/tests/unit/cli/administration/deploy/development/test_compose.py @@ -12,8 +12,6 @@ from cli.administration.deploy.development.compose import Compose class TestComposeUpRetries(unittest.TestCase): def _compose(self) -> Compose: - # repo_root is only used as a cwd in subprocess calls; for these unit tests - # we do not execute real subprocesses. return Compose(repo_root=Path("/tmp/infinito-nexus"), distro="arch") @patch("time.sleep", autospec=True) @@ -22,7 +20,6 @@ class TestComposeUpRetries(unittest.TestCase): ) -> None: compose = self._compose() - # Fail 2 times, then succeed. compose.run = MagicMock( side_effect=[ subprocess.CalledProcessError(1, ["docker", "compose"], "out1", "err1"), @@ -42,13 +39,10 @@ class TestComposeUpRetries(unittest.TestCase): ] compose._compose_up_with_retries(args, attempts=6, delay_s=30) - # 3 calls: fail, fail, succeed self.assertEqual(compose.run.call_count, 3) - # Sleep after each failure (2 times), not after success self.assertEqual(sleep_mock.call_count, 2) sleep_mock.assert_any_call(30) - # Ensure all sleeps used exactly 30 seconds self.assertTrue(all(call.args == (30,) for call in sleep_mock.call_args_list)) @patch("time.sleep", autospec=True) @@ -57,7 +51,6 @@ class TestComposeUpRetries(unittest.TestCase): ) -> None: compose = self._compose() - # Always fail. compose.run = MagicMock( side_effect=subprocess.CalledProcessError( 1, ["docker", "compose"], "out", "err" @@ -69,10 +62,8 @@ class TestComposeUpRetries(unittest.TestCase): with self.assertRaises(subprocess.CalledProcessError): compose._compose_up_with_retries(args, attempts=6, delay_s=30) - # Called exactly 6 times self.assertEqual(compose.run.call_count, 6) - # Sleep between attempts: after attempts 1..5 => 5 sleeps self.assertEqual(sleep_mock.call_count, 5) self.assertTrue(all(call.args == (30,) for call in sleep_mock.call_args_list)) @@ -101,6 +92,8 @@ class TestComposeUpRetries(unittest.TestCase): "GITHUB_ACTIONS": "true", "INFINITO_RUNNING_ON_GITHUB": "true", "CI": "true", + "INFINITO_GIT_COMMON_DIR": "", + "INFINITO_CACHE_NETWORK": "", }, clear=False, ) @@ -125,7 +118,6 @@ class TestComposeUpRetries(unittest.TestCase): cmd = run_mock.call_args.args[0] env = run_mock.call_args.kwargs["env"] - # CI: cache override is not layered in. self.assertEqual( cmd, [ @@ -148,6 +140,8 @@ class TestComposeUpRetries(unittest.TestCase): "GITHUB_ACTIONS": "", "INFINITO_RUNNING_ON_GITHUB": "", "CI": "", + "INFINITO_GIT_COMMON_DIR": "", + "INFINITO_CACHE_NETWORK": "", }, clear=False, ) @@ -164,7 +158,6 @@ class TestComposeUpRetries(unittest.TestCase): cmd = run_mock.call_args.args[0] env = run_mock.call_args.kwargs["env"] - # Local: cache override is layered onto compose.yml. self.assertEqual( cmd, [ @@ -195,7 +188,6 @@ class TestComposeUpRetries(unittest.TestCase): "INFINITO_BUILD": "1", "INFINITO_IMAGE": "infinito-debian", "INFINITO_PULL_POLICY": "never", - # CI-pinned so cache stays inactive; this test asserts build behaviour only. "CI": "true", "GITHUB_ACTIONS": "true", "INFINITO_RUNNING_ON_GITHUB": "true", @@ -258,6 +250,8 @@ class TestComposeUpRetries(unittest.TestCase): "CI": "", "GITHUB_ACTIONS": "", "INFINITO_RUNNING_ON_GITHUB": "", + "INFINITO_GIT_COMMON_DIR": "", + "INFINITO_CACHE_NETWORK": "", }, clear=False, ) @@ -272,7 +266,6 @@ class TestComposeUpRetries(unittest.TestCase): compose.up(run_entry_init=False) - # Cache services precede coredns + infinito so depends_on health gates resolve. compose._compose_up_with_retries.assert_called_once_with( [ "up", diff --git a/tests/unit/cli/administration/deploy/development/test_env_compose_file_args.py b/tests/unit/cli/administration/deploy/development/test_env_compose_file_args.py new file mode 100644 index 0000000000..195f4551d2 --- /dev/null +++ b/tests/unit/cli/administration/deploy/development/test_env_compose_file_args.py @@ -0,0 +1,102 @@ +"""Unit tests for compose_file_args. + +Pins which override files each runtime context layers on compose.yml. Every +override is gated on the resource it needs rather than on the instance slot, +because an unsatisfied `:?` guard inside one of them aborts the whole stack. +""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from cli.administration.deploy.development.env import compose_file_args + +_LOCAL_PRIMARY = { + "GITHUB_ACTIONS": "", + "INFINITO_RUNNING_ON_GITHUB": "", + "CI": "", + "INFINITO_INSTANCE": "0", + "INFINITO_GIT_COMMON_DIR": "", + "INFINITO_CACHE_NETWORK": "", + "INFINITO_PUBLISH_PORTS": "", +} + +_WORKTREE = { + **_LOCAL_PRIMARY, + "INFINITO_INSTANCE": "2", + "INFINITO_GIT_COMMON_DIR": "/repo/.git", + "INFINITO_CACHE_NETWORK": "primary_default", +} + + +class TestComposeFileArgs(unittest.TestCase): + @patch.dict(os.environ, _LOCAL_PRIMARY, clear=False) + def test_primary_instance_loads_the_cache_stack(self) -> None: + self.assertEqual( + compose_file_args(), + ["-f", "compose.yml", "-f", "compose/cache.override.yml"], + ) + + @patch.dict(os.environ, _WORKTREE, clear=False) + def test_worktree_adds_the_git_and_shared_cache_overrides(self) -> None: + self.assertEqual( + compose_file_args(), + [ + "-f", + "compose.yml", + "-f", + "compose/worktree.override.yml", + "-f", + "compose/cache.override.yml", + "-f", + "compose/cache.shared.override.yml", + ], + ) + + @patch.dict(os.environ, {**_LOCAL_PRIMARY, "INFINITO_INSTANCE": "5"}, clear=False) + def test_a_stray_slot_alone_changes_nothing(self) -> None: + self.assertEqual( + compose_file_args(), + ["-f", "compose.yml", "-f", "compose/cache.override.yml"], + ) + + @patch.dict(os.environ, {**_LOCAL_PRIMARY, "INFINITO_GIT_COMMON_DIR": "/repo/.git"}) + def test_a_shared_git_dir_alone_adds_only_the_worktree_override(self) -> None: + self.assertEqual( + compose_file_args(), + [ + "-f", + "compose.yml", + "-f", + "compose/worktree.override.yml", + "-f", + "compose/cache.override.yml", + ], + ) + + @patch.dict(os.environ, {**_WORKTREE, "CI": "true"}, clear=False) + def test_ci_keeps_the_git_mount_but_drops_every_cache_override(self) -> None: + self.assertEqual( + compose_file_args(), + ["-f", "compose.yml", "-f", "compose/worktree.override.yml"], + ) + + @patch.dict(os.environ, {**_LOCAL_PRIMARY, "CI": "true"}, clear=False) + def test_ci_loads_no_override_at_all(self) -> None: + self.assertEqual(compose_file_args(), ["-f", "compose.yml"]) + + @patch.dict( + os.environ, + {**_LOCAL_PRIMARY, "INFINITO_PUBLISH_PORTS": "false"}, + clear=False, + ) + def test_unpublished_ports_append_the_noports_override(self) -> None: + self.assertEqual( + compose_file_args()[-2:], ["-f", "compose/noports.override.yml"] + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/unit/cli/administration/deploy/development/test_profile.py b/tests/unit/cli/administration/deploy/development/test_profile.py index 3c55a4f352..7da18d6898 100644 --- a/tests/unit/cli/administration/deploy/development/test_profile.py +++ b/tests/unit/cli/administration/deploy/development/test_profile.py @@ -8,11 +8,13 @@ from unittest.mock import patch from cli.administration.deploy.development.profile import Profile -# Blank explicit signals; clear=True would unset PATH. _BLANK_CI_ENV = { "GITHUB_ACTIONS": "", "INFINITO_RUNNING_ON_GITHUB": "", "CI": "", + "INFINITO_INSTANCE": "0", + "INFINITO_GIT_COMMON_DIR": "", + "INFINITO_CACHE_NETWORK": "", } @@ -73,5 +75,45 @@ class TestProfileRegistryCacheActive(unittest.TestCase): self.assertEqual(p.registry_cache_active(), not p.is_ci()) +class TestProfileInstance(unittest.TestCase): + @patch.dict(os.environ, {**_BLANK_CI_ENV, "INFINITO_INSTANCE": ""}, clear=False) + def test_unset_instance_is_primary(self) -> None: + self.assertEqual(Profile().instance(), 0) + + @patch.dict(os.environ, {**_BLANK_CI_ENV, "INFINITO_INSTANCE": "7"}, clear=False) + def test_numeric_instance_is_parsed(self) -> None: + self.assertEqual(Profile().instance(), 7) + + @patch.dict(os.environ, {**_BLANK_CI_ENV, "INFINITO_INSTANCE": " 3 "}, clear=False) + def test_surrounding_whitespace_is_tolerated(self) -> None: + self.assertEqual(Profile().instance(), 3) + + @patch.dict(os.environ, {**_BLANK_CI_ENV, "INFINITO_INSTANCE": "abc"}, clear=False) + def test_garbage_falls_back_to_primary(self) -> None: + self.assertEqual(Profile().instance(), 0) + + +class TestProfileOwnsCacheStack(unittest.TestCase): + @patch.dict(os.environ, _BLANK_CI_ENV, clear=False) + def test_owns_the_stack_when_no_network_was_handed_over(self) -> None: + self.assertTrue(Profile().owns_cache_stack()) + + @patch.dict( + os.environ, {**_BLANK_CI_ENV, "INFINITO_CACHE_NETWORK": "primary_default"} + ) + def test_shares_instead_of_owning_when_a_network_is_named(self) -> None: + p = Profile() + self.assertFalse(p.owns_cache_stack()) + self.assertTrue(p.registry_cache_active()) + + @patch.dict(os.environ, {**_BLANK_CI_ENV, "INFINITO_INSTANCE": "7"}, clear=False) + def test_a_stray_slot_alone_does_not_give_the_stack_away(self) -> None: + self.assertTrue(Profile().owns_cache_stack()) + + @patch.dict(os.environ, {**_BLANK_CI_ENV, "CI": "true"}, clear=False) + def test_ci_never_owns_the_stack(self) -> None: + self.assertFalse(Profile().owns_cache_stack()) + + if __name__ == "__main__": # pragma: no cover unittest.main() diff --git a/utils/env/handlers/passthrough.py b/utils/env/handlers/passthrough.py index 18b3507658..084c8f3101 100644 --- a/utils/env/handlers/passthrough.py +++ b/utils/env/handlers/passthrough.py @@ -88,6 +88,9 @@ STATIC_KEYS = ( "INFINITO_RESCUE_DIAGNOSTICS_BASE", "INFINITO_RUNNER_PREFIX", "INFINITO_TIMEOUT_MULTIPLIER", + "INFINITO_INSTANCE", + "INFINITO_GIT_COMMON_DIR", + "INFINITO_CACHE_NETWORK", "INFINITO_CACHE_REGISTRY_HOST_PATH", "INFINITO_CACHE_REGISTRY_CA_HOST_PATH", "INFINITO_CACHE_REGISTRY_IP",