Files
computer-playbook/tests/unit/cli/deploy/development/test_compose.py
T
kevinveenbirkenbachandClaude Opus 4.7 eb45f0ffaf feat(cache): inner-build override + TTL env-var + SPOT docs
Extend the local cache stack with three independent capabilities:

1. Inner-`dockerd` build coverage for HTTP-only package mirrors.
   `roles/sys-svc-compose/files/compose.py` (the runner's `compose`
   wrapper) auto-generates a transient `compose.cache.override.yml`
   per app when `INFINITO_PACKAGE_CACHE_FRONTEND_IP` is exported. The
   override pins `build.extra_hosts` for `deb.debian.org`,
   `archive.ubuntu.com`, and `security.ubuntu.com` to the frontend.
   `pull.py` now delegates to the wrapper so build/pull operations see
   the same `-f` set the wrapper auto-detects. nginx in the frontend
   gains a port-80 listener with path rewrites for those hostnames,
   and `package.sh` registers two new Nexus proxies
   (`apt-debian-security`, `apt-ubuntu-security`) for the security
   suites whose upstream URL prefix differs from the main archive.

   Alpine is intentionally excluded from the inner-build extra_hosts:
   3.18+ defaults to HTTPS, and inner-`dockerd` build containers do
   not import the frontend CA. Routing apk through the frontend would
   trip TLS verification.

2. General cache freshness env-var. `scripts/meta/env/cache/package.sh`
   exports `INFINITO_PACKAGE_CACHE_MAX_AGE_MIN` (default `129600`,
   i.e. 90 days). The bootstrap helper consumes it for every Nexus
   proxy's `contentMaxAge`, `metadataMaxAge`, and
   `negativeCache.timeToLive`, replacing the previous 1440-minute
   literal. Lower the value when chasing fast-moving upstreams.

3. SPOT cache documentation at `docs/contributing/environment/cache.md`.
   `compose.yml.md`, `variables.md`, the per-service READMEs, and
   `requirements/012-...` now link to it instead of restating the
   stack's behaviour. Net documentation lines drop substantially while
   the cache page becomes the single user-facing reference.

Also: code-comment cleanup per `docs/contributing/documentation.md`
guidelines across all touched .py / .sh / .yml files. Multi-paragraph
docstrings, banner separators, and restate-the-obvious comments are
removed; one-line WHY comments remain where the rationale is not
already visible in the code.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 01:08:41 +02:00

294 lines
9.7 KiB
Python

# tests/cli/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.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", "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", "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", "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",
"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)
self.assertNotIn("INFINITO_REGISTRY_CACHE_PROXY_CONF", env)
@patch.dict(
os.environ,
{
"INFINITO_IMAGE": "test-image/arch",
"GITHUB_ACTIONS": "",
"RUNNING_ON_GITHUB": "",
"CI": "",
# Tests bypass BASH_ENV; set explicitly so cache_env_overrides() passes.
"INFINITO_PACKAGE_CACHE_FRONTEND_CA_DIR": "/var/cache/infinito/test/ca",
},
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_REGISTRY_CACHE_PROXY_CONF"],
"./compose/registry-cache/proxy.conf",
)
self.assertTrue(
env["INFINITO_PACKAGE_CACHE_FRONTEND_CA_FILE"].endswith("/ca.crt")
)
@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",
"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", "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",
"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", "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": "",
"RUNNING_ON_GITHUB": "",
# Tests bypass BASH_ENV; set explicitly so cache_env_overrides() passes.
"INFINITO_PACKAGE_CACHE_FRONTEND_CA_DIR": "/var/cache/infinito/test/ca",
},
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",
"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()