Files
computer-playbook/tests/unit/cli/administration/deploy/development/test_compose.py
T
kevinveenbirkenbachandClaude Opus 4.7 a26bfc4e94 refactor(cli): reorg admin/contributing tree + per-folder help system
Module moves (with imports, paths, argv literals, scripts, docs, and
parents[N] formulas updated in lockstep):

- cli/deploy             -> cli/administration/deploy
- cli/validate/inventory -> cli/administration/inventory/validate
- cli/vault              -> cli/administration/secrets/vault
- cli/administration/inventory/encrypt -> cli/administration/secrets/seal
- cli/mirror             -> cli/contributing/mirror

The new cli/administration/secrets cluster collocates the whole-file
ansible-vault wrapper next to the inventory-aware per-field sealer.
The user-facing CLI subcommand paths follow the package layout, e.g.
"infinito administration deploy dedicated <inv>" and
"infinito administration inventory validate <inv>".

Help system redesign in cli/core/{discovery,help,app}.py:

- Discovery now distinguishes runnable commands (directories with
  __main__.py) from category folders (directories that only group
  further commands). Empty support packages are filtered out.
- Each category folder MAY carry a README.md; its H1 heading is used
  as the title and its first paragraph as the one-line description in
  the help output.
- "infinito" / "infinito --help" prints only the top-level entries
  (one level deep). "infinito <category>" navigates into a folder and
  shows its sub-folders and commands one level deep, with the README
  title as the page heading and the invocation as a dim subtitle.
- New "--tree [N]" flag walks the tree under the (optional) path
  prefix recursively. N caps the depth (1-based); omit N for an
  unbounded walk. The output uses Unicode tree connectors, an emoji
  per entry (folder for categories, gear for commands), a globally
  aligned description column, and multi-line wrapped descriptions
  with tree-continuity bars on continuation lines.
- Category and command listings use the full invocation
  ("infinito meta callorder") rather than the bare leaf name, so the
  output is copy-pasteable.

Convention doc + 17 CLI READMEs:

- New docs/contributing/artefact/files/cli/readme_md.md describes the
  H1+paragraph layout the help system reads, and how it differs from
  role/docs READMEs.
- README.md added for cli/{administration, build, contributing, meta}
  and their immediate sub-folders, plus cli/meta/roles/applications
  and its resolution sub-folder. Each follows the "title with trailing
  emoji + single descriptive paragraph" convention.

Mirror resolver/sync description strings (the help system reads them
from argparse --help output) added so they no longer surface as
"options:" in the global index. cli/mirror/cleanup already had a
description and is unchanged.

Fixes uncovered by the move:

- import unittest.mock added to four dedicated-deploy tests so
  unittest.mock.patch resolves under stdlib unittest discovery
  (Python 3.13 no longer surfaces unittest.mock through "import
  unittest" alone).
- parents[N] index in the new
  cli/administration/inventory/validate/__init__.py corrected from
  3 to 4 to match the deeper layout.
- test_paths.INVENTORY_VALIDATOR_PATH assertion and test_runner
  substring assertions updated to match the new validator path.

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

294 lines
9.7 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", "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()