mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
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>
294 lines
9.7 KiB
Python
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()
|