Files
computer-playbook/tests/unit/cli/administration/deploy/development/test_compose.py
T
kevinveenbirkenbachandClaude Opus 4.7 eb4bcd23a8 refactor(env/spot): forbid .get() defaults + cache namespace rename
Three new lint tests close gaps in the INFINITO_* SPOT contract:
* tests/lint/filesystem/env/test_default_env_keys_handled.py
  Every default.env key must be wired through a handler.
* tests/lint/filesystem/env/test_used_keys_declared.py
  Every INFINITO_* in .sh/.py must live in env/{default,ci}.env or
  a handler; per-line `# nocheck:` suppresses runtime-only refs.
* tests/lint/filesystem/python/test_no_infinito_get_defaults.py
  Forbid ANY default in `.get("INFINITO_*", ...)` -- including the
  empty-string fallback (which silently masks an unset key). The
  nocheck check walks every physical line the call spans so an
  autoformat-split call still honours the marker.

Cache namespace flattens to a single prefix:
  INFINITO_PACKAGE_CACHE_*  -> INFINITO_CACHE_PACKAGE_*
  INFINITO_REGISTRY_CACHE_* -> INFINITO_CACHE_REGISTRY_*
The five previously driver-injected paths (APT_LIST, NPMRC,
PIP_CONF, REGISTRY_PROXY_CONF, FRONTEND_CA_FILE) move into
env/default.env and passthrough.STATIC_KEYS; the
`cache_env_overrides()` helper and its two call sites are gone
along with the test-fixture override for FRONTEND_CA_DIR.

SPOT-strict rewrites:
  - 19 sites: `something.get("INFINITO_X", "")` -> subscript
    `something["INFINITO_X"]` (loud KeyError when env loader did
    not run; all keys live in env/default.env).
  - 2 handler-internal bootstrap reads suppressed with
    `# nocheck: handler-bootstrap-read`.
  - 30 runtime-only / driver-injected / test-fixture references
    annotated with `# nocheck: <reason>` per-line.
  - Unit tests that previously removed SPOT-guaranteed keys via
    `{k: v ... if k \!= KEY}` or `clear=True` switch to setting
    the key to "" explicitly, matching the new contract.

Side fixes:
  - INFINITO_DNS_DOMAIN + INFINITO_DNS_HOSTS_FILE move from
    shell-side defaults in common.sh to env/default.env and
    passthrough.STATIC_KEYS.
  - dns_systemd_is_operational() drops the dead
    INFINITO_DNS_FORCE_HOSTS_FALLBACK override branch (the
    variable was never set anywhere).
  - INFINITO_WORKER_FETCH switches from `os.environ.get(KEY, "1")`
    to `os.environ[KEY]`.
  - scripts/tests/code/wrapper.sh exec args move into a bash
    array so `# nocheck:` on INFINITO_TEST_TYPE can live on its
    own element line (SC2215 fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:26:16 +02:00

307 lines
9.6 KiB
Python

# tests/cli/administration/deploy/development/test_compose.py
from __future__ import annotations
import os
import subprocess
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
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)
def test_compose_up_with_retries_succeeds_after_transient_failures(
self, sleep_mock: MagicMock
) -> None:
compose = self._compose()
# Fail 2 times, then succeed.
compose.run = MagicMock(
side_effect=[
subprocess.CalledProcessError(1, ["docker", "compose"], "out1", "err1"),
subprocess.CalledProcessError(1, ["docker", "compose"], "out2", "err2"),
subprocess.CompletedProcess(
["docker", "compose"], 0, stdout="", stderr=""
),
]
)
args = [
"--env-file",
"env/ci.env",
"up",
"-d",
"--no-build",
"coredns",
"infinito",
]
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)
def test_compose_up_with_retries_raises_after_exhausting_attempts(
self, sleep_mock: MagicMock
) -> None:
compose = self._compose()
# Always fail.
compose.run = MagicMock(
side_effect=subprocess.CalledProcessError(
1, ["docker", "compose"], "out", "err"
)
)
args = ["--env-file", "env/ci.env", "up", "-d", "coredns", "infinito"]
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))
@patch("time.sleep", autospec=True)
def test_compose_up_with_retries_no_sleep_if_first_try_succeeds(
self, sleep_mock: MagicMock
) -> None:
compose = self._compose()
compose.run = MagicMock(
return_value=subprocess.CompletedProcess(
["docker", "compose"], 0, stdout="", stderr=""
)
)
args = ["--env-file", "env/ci.env", "up", "-d", "coredns", "infinito"]
compose._compose_up_with_retries(args, attempts=6, delay_s=30)
self.assertEqual(compose.run.call_count, 1)
sleep_mock.assert_not_called()
@patch.dict(
os.environ,
{
"INFINITO_IMAGE": "test-image/arch",
"GITHUB_ACTIONS": "true",
"INFINITO_RUNNING_ON_GITHUB": "true",
"CI": "true",
},
clear=False,
)
@patch("subprocess.run", autospec=True)
def test_run_uses_only_ci_profile_on_github_runner(
self, run_mock: MagicMock
) -> None:
compose = self._compose()
run_mock.return_value = subprocess.CompletedProcess(
["docker", "compose", "--profile", "ci", "ps", "-q", "infinito"],
0,
stdout="cid\n",
stderr="",
)
r = compose.run(["ps", "-q", "infinito"], check=False, capture=True)
self.assertEqual(r.returncode, 0)
self.assertEqual(run_mock.call_count, 1)
cmd = run_mock.call_args.args[0]
env = run_mock.call_args.kwargs["env"]
# CI: only the ci profile fires; cache override is not layered in.
self.assertEqual(
cmd,
[
"docker",
"compose",
"-f",
"compose.yml",
"--profile",
"ci",
"ps",
"-q",
"infinito",
],
)
self.assertEqual(env["INFINITO_DISTRO"], "arch")
self.assertNotIn("COMPOSE_PROFILES", env)
@patch.dict(
os.environ,
{
"INFINITO_IMAGE": "test-image/arch",
"GITHUB_ACTIONS": "",
"INFINITO_RUNNING_ON_GITHUB": "",
"CI": "",
},
clear=False,
)
@patch("subprocess.run", autospec=True)
def test_run_activates_cache_profile_locally(self, run_mock: MagicMock) -> None:
compose = self._compose()
run_mock.return_value = subprocess.CompletedProcess(
["docker", "compose"], 0, stdout="", stderr=""
)
compose.run(["ps", "-q", "infinito"], check=False, capture=True)
cmd = run_mock.call_args.args[0]
env = run_mock.call_args.kwargs["env"]
# Local: cache override is layered onto compose.yml; no --profile cache.
self.assertEqual(
cmd,
[
"docker",
"compose",
"-f",
"compose.yml",
"-f",
"compose/cache.override.yml",
"--profile",
"ci",
"ps",
"-q",
"infinito",
],
)
self.assertEqual(
env["INFINITO_CACHE_REGISTRY_PROXY_CONF"], # nocheck: test-fixture
"./compose/registry-cache/proxy.conf",
)
self.assertTrue(
env["INFINITO_CACHE_PACKAGE_FRONTEND_CA_FILE"].endswith(
"/ca.crt"
) # nocheck: test-fixture
)
@patch.dict(
os.environ,
{
"INFINITO_NO_BUILD": "0",
"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",
},
clear=False,
)
def test_up_builds_when_no_build_flag_is_disabled(self) -> None:
compose = self._compose()
compose._render_coredns_corefile = MagicMock()
compose._compose_up_with_retries = MagicMock()
compose.wait_for_healthy = MagicMock()
compose.up(run_entry_init=False)
compose._compose_up_with_retries.assert_called_once_with(
["--env-file", "env/ci.env", "up", "-d", "coredns", "infinito"],
attempts=6,
delay_s=30,
)
compose.wait_for_healthy.assert_called_once_with()
@patch.dict(
os.environ,
{
"INFINITO_NO_BUILD": "1",
"INFINITO_IMAGE": "test-image/arch",
"CI": "true",
"GITHUB_ACTIONS": "true",
"INFINITO_RUNNING_ON_GITHUB": "true",
},
clear=False,
)
def test_up_skips_build_when_no_build_flag_is_enabled(self) -> None:
compose = self._compose()
compose._render_coredns_corefile = MagicMock()
compose._compose_up_with_retries = MagicMock()
compose.wait_for_healthy = MagicMock()
compose.up(run_entry_init=False)
compose._compose_up_with_retries.assert_called_once_with(
[
"--env-file",
"env/ci.env",
"up",
"-d",
"--no-build",
"coredns",
"infinito",
],
attempts=6,
delay_s=30,
)
compose.wait_for_healthy.assert_called_once_with()
@patch.dict(
os.environ,
{
"INFINITO_NO_BUILD": "0",
"INFINITO_IMAGE": "infinito-debian",
"INFINITO_PULL_POLICY": "never",
"CI": "",
"GITHUB_ACTIONS": "",
"INFINITO_RUNNING_ON_GITHUB": "",
},
clear=False,
)
def test_up_includes_cache_services_when_local(self) -> None:
compose = self._compose()
compose._render_coredns_corefile = MagicMock()
compose._compose_up_with_retries = MagicMock()
compose.wait_for_healthy = MagicMock()
compose._bootstrap_package_cache = MagicMock()
compose._generate_package_frontend_certs = MagicMock()
compose._install_package_frontend_ca_in_runner = MagicMock()
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(
[
"--env-file",
"env/ci.env",
"up",
"-d",
"registry-cache",
"package-cache",
"package-cache-frontend",
"coredns",
"infinito",
],
attempts=6,
delay_s=30,
)
compose.wait_for_healthy.assert_called_once_with()
compose._bootstrap_package_cache.assert_called_once()
compose._generate_package_frontend_certs.assert_called_once()
compose._install_package_frontend_ca_in_runner.assert_called_once()
if __name__ == "__main__": # pragma: no cover
unittest.main()