mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).
This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.
Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:
* 7 `src:` values built from a role variable holding a bare filename
(svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
The variable doubles as the destination name inside the container, so the
language segment belongs at the call site, not in the variable.
* `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
18 roles ship exactly that and have no template fallback: the play would
have died on each.
* `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
interface now reads `files/javascript/javascript.js`.
* `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
path through `path_absolute(... 'files', ...)`.
* 4 `ansible.builtin.script:` invocations naming a bare command
(pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
* `dev-python/files/shell/install.sh` pointed at a sibling role's script.
Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
311 lines
11 KiB
Python
311 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import unittest
|
|
import unittest.mock
|
|
from typing import Any
|
|
|
|
from cli.administration.deploy.dedicated import runner
|
|
from cli.administration.inventory.provision.services_disabler import (
|
|
ServicesDisabledConflictError,
|
|
)
|
|
|
|
|
|
class TestRunAnsiblePlaybook(unittest.TestCase):
|
|
def _fake_run_side_effect(
|
|
self,
|
|
calls_store: list[tuple[list[str], dict[str, Any]]],
|
|
ansible_rc: int = 0,
|
|
):
|
|
"""
|
|
Side effect for subprocess.run that:
|
|
- records all commands + kwargs to calls_store
|
|
- returns a CompletedProcess with ansible_rc for ansible-playbook
|
|
- returns success (rc=0) for all other commands
|
|
"""
|
|
|
|
def _side_effect(cmd, *args, **kwargs):
|
|
norm_cmd = cmd if isinstance(cmd, list) else [cmd]
|
|
|
|
calls_store.append((norm_cmd, dict(kwargs)))
|
|
|
|
if norm_cmd and norm_cmd[0] == "ansible-playbook":
|
|
return subprocess.CompletedProcess(
|
|
norm_cmd,
|
|
ansible_rc,
|
|
stdout="ANSIBLE_STDOUT\n",
|
|
stderr="ANSIBLE_STDERR\n",
|
|
)
|
|
|
|
return subprocess.CompletedProcess(norm_cmd, 0, stdout="", stderr="")
|
|
|
|
return _side_effect
|
|
|
|
@unittest.mock.patch("subprocess.run")
|
|
def test_run_ansible_playbook_builds_correct_command_and_uses_repo_root_cwd(
|
|
self, mock_run
|
|
):
|
|
calls: list[tuple[list[str], dict[str, Any]]] = []
|
|
mock_run.side_effect = self._fake_run_side_effect(calls, ansible_rc=0)
|
|
|
|
repo_root = "/repo"
|
|
playbook_path = "/repo/playbook.yml"
|
|
inventory_validator_path = (
|
|
"/repo/cli/administration/inventory/validate/__main__.py"
|
|
)
|
|
|
|
modes: dict[str, Any] = {
|
|
"MODE_CLEANUP": True,
|
|
"MODE_ASSERT": True,
|
|
"MODE_DEBUG": True,
|
|
}
|
|
|
|
inventory_path = "/etc/inventories/github-ci/devices.yml"
|
|
limit = "localhost"
|
|
allowed_apps = ["web-app-foo", "web-app-bar"]
|
|
password_file = "/etc/inventories/github-ci/.password"
|
|
|
|
with unittest.mock.patch(
|
|
"cli.administration.deploy.dedicated.runner.assert_services_disabled_inventory_consistency_from_env"
|
|
) as mock_services_disabled_guard:
|
|
runner.run_ansible_playbook(
|
|
repo_root=repo_root,
|
|
playbook_path=playbook_path,
|
|
inventory_validator_path=inventory_validator_path,
|
|
inventory=inventory_path,
|
|
modes=modes,
|
|
limit=limit,
|
|
allowed_applications=allowed_apps,
|
|
password_file=password_file,
|
|
verbose=1,
|
|
skip_build=False,
|
|
diff=True,
|
|
ansible_args=None,
|
|
)
|
|
|
|
mock_services_disabled_guard.assert_called_once()
|
|
|
|
def was_called(cmd: list[str]) -> bool:
|
|
return any(call_cmd == cmd for call_cmd, _kw in calls)
|
|
|
|
self.assertTrue(was_called(["make", "clean"]), "Expected 'make clean'")
|
|
self.assertTrue(was_called(["make", "setup"]), "Expected 'make setup'")
|
|
|
|
for call_cmd, kw in calls:
|
|
if call_cmd and call_cmd[0] == "make":
|
|
self.assertEqual(
|
|
kw.get("cwd"),
|
|
repo_root,
|
|
f"Expected make call to use cwd={repo_root}, got {kw.get('cwd')}",
|
|
)
|
|
|
|
inv_calls = [
|
|
(call_cmd, kw)
|
|
for call_cmd, kw in calls
|
|
if isinstance(call_cmd, list)
|
|
and any("validate/__main__.py" in part for part in call_cmd)
|
|
]
|
|
self.assertTrue(
|
|
inv_calls,
|
|
"Expected inventory validation call (... validate/__main__.py ...)",
|
|
)
|
|
for _call_cmd, kw in inv_calls:
|
|
self.assertEqual(kw.get("cwd"), repo_root)
|
|
|
|
self.assertGreaterEqual(len(calls), 1)
|
|
last_cmd, last_kw = calls[-1]
|
|
self.assertEqual(last_cmd[0], "ansible-playbook")
|
|
self.assertEqual(last_kw.get("cwd"), repo_root)
|
|
|
|
self.assertIn("-i", last_cmd)
|
|
self.assertIn(inventory_path, last_cmd)
|
|
idx_inv = last_cmd.index(inventory_path)
|
|
self.assertGreater(len(last_cmd), idx_inv + 1)
|
|
self.assertEqual(last_cmd[idx_inv + 1], playbook_path)
|
|
|
|
self.assertIn("-l", last_cmd)
|
|
self.assertIn(limit, last_cmd)
|
|
|
|
last_cmd_str = " ".join(last_cmd)
|
|
self.assertIn("APPLICATIONS_WHITELIST=web-app-foo,web-app-bar", last_cmd_str)
|
|
|
|
modes_index = len(last_cmd) - 1 - last_cmd[::-1].index("-e")
|
|
self.assertEqual(
|
|
json.loads(last_cmd[modes_index + 1]),
|
|
{"MODE_CLEANUP": True, "MODE_ASSERT": True, "MODE_DEBUG": True},
|
|
)
|
|
|
|
self.assertIn("--vault-password-file", last_cmd)
|
|
self.assertIn(password_file, last_cmd)
|
|
|
|
self.assertIn("--diff", last_cmd)
|
|
|
|
self.assertTrue(
|
|
any(arg.startswith("-vvv") for arg in last_cmd),
|
|
"Expected at least -vvv because MODE_DEBUG=True",
|
|
)
|
|
|
|
self.assertNotIn("text", last_kw)
|
|
self.assertNotIn("capture_output", last_kw)
|
|
|
|
@unittest.mock.patch("subprocess.run")
|
|
def test_run_ansible_playbook_appends_passthrough_args_last_for_overrides(
|
|
self, mock_run
|
|
):
|
|
calls: list[tuple[list[str], dict[str, Any]]] = []
|
|
mock_run.side_effect = self._fake_run_side_effect(calls, ansible_rc=0)
|
|
|
|
repo_root = "/repo"
|
|
playbook_path = "/repo/playbook.yml"
|
|
inventory_validator_path = (
|
|
"/repo/cli/administration/inventory/validate/__main__.py"
|
|
)
|
|
|
|
modes: dict[str, Any] = {
|
|
"MODE_CLEANUP": False,
|
|
"MODE_ASSERT": False,
|
|
"MODE_DEBUG": False,
|
|
}
|
|
|
|
inventory_path = "/etc/inventories/github-ci/devices.yml"
|
|
|
|
passthrough = [
|
|
"--tags",
|
|
"deploy",
|
|
"-e",
|
|
"APPLICATIONS_WHITELIST=override-app",
|
|
"-e",
|
|
"MODE_DEBUG=true",
|
|
]
|
|
|
|
with unittest.mock.patch(
|
|
"cli.administration.deploy.dedicated.runner.assert_services_disabled_inventory_consistency_from_env"
|
|
):
|
|
runner.run_ansible_playbook(
|
|
repo_root=repo_root,
|
|
playbook_path=playbook_path,
|
|
inventory_validator_path=inventory_validator_path,
|
|
inventory=inventory_path,
|
|
modes=modes,
|
|
limit=None,
|
|
allowed_applications=["web-app-foo"],
|
|
password_file=None,
|
|
verbose=0,
|
|
skip_build=True,
|
|
diff=False,
|
|
ansible_args=passthrough,
|
|
)
|
|
|
|
last_cmd, _last_kw = calls[-1]
|
|
self.assertEqual(last_cmd[0], "ansible-playbook")
|
|
|
|
self.assertGreaterEqual(len(last_cmd), len(passthrough))
|
|
self.assertEqual(last_cmd[-len(passthrough) :], passthrough)
|
|
|
|
cmd_str = " ".join(last_cmd)
|
|
self.assertIn("APPLICATIONS_WHITELIST=web-app-foo", cmd_str)
|
|
self.assertIn("APPLICATIONS_WHITELIST=override-app", cmd_str)
|
|
|
|
@unittest.mock.patch("subprocess.run")
|
|
def test_run_ansible_playbook_failure_exits_with_code_and_skips_phases(
|
|
self, mock_run
|
|
):
|
|
calls: list[tuple[list[str], dict[str, Any]]] = []
|
|
mock_run.side_effect = self._fake_run_side_effect(calls, ansible_rc=4)
|
|
|
|
repo_root = "/repo"
|
|
playbook_path = "/repo/playbook.yml"
|
|
inventory_validator_path = (
|
|
"/repo/cli/administration/inventory/validate/__main__.py"
|
|
)
|
|
|
|
modes: dict[str, Any] = {
|
|
"MODE_CLEANUP": False,
|
|
"MODE_ASSERT": False,
|
|
"MODE_DEBUG": False,
|
|
}
|
|
|
|
with (
|
|
unittest.mock.patch(
|
|
"cli.administration.deploy.dedicated.runner.assert_services_disabled_inventory_consistency_from_env"
|
|
),
|
|
self.assertRaises(SystemExit) as ctx,
|
|
):
|
|
runner.run_ansible_playbook(
|
|
repo_root=repo_root,
|
|
playbook_path=playbook_path,
|
|
inventory_validator_path=inventory_validator_path,
|
|
inventory="/etc/inventories/github-ci/devices.yml",
|
|
modes=modes,
|
|
limit=None,
|
|
allowed_applications=None,
|
|
password_file=None,
|
|
verbose=0,
|
|
skip_build=True,
|
|
diff=False,
|
|
ansible_args=None,
|
|
)
|
|
|
|
self.assertEqual(ctx.exception.code, 4)
|
|
|
|
self.assertTrue(
|
|
any(
|
|
call_cmd and call_cmd[0] == "ansible-playbook"
|
|
for call_cmd, _kw in calls
|
|
),
|
|
"ansible-playbook should have been invoked once in the error path",
|
|
)
|
|
|
|
self.assertFalse(any(call_cmd == ["make", "clean"] for call_cmd, _kw in calls))
|
|
self.assertFalse(any(call_cmd == ["make", "setup"] for call_cmd, _kw in calls))
|
|
self.assertFalse(
|
|
any(
|
|
any("validate/__main__.py" in part for part in call_cmd)
|
|
for call_cmd, _kw in calls
|
|
),
|
|
"Inventory validation should be skipped when MODE_ASSERT is False",
|
|
)
|
|
|
|
@unittest.mock.patch("subprocess.run")
|
|
def test_run_ansible_playbook_fails_early_on_services_disabled_conflict(
|
|
self, mock_run
|
|
):
|
|
calls: list[tuple[list[str], dict[str, Any]]] = []
|
|
mock_run.side_effect = self._fake_run_side_effect(calls, ansible_rc=0)
|
|
|
|
with (
|
|
unittest.mock.patch(
|
|
"cli.administration.deploy.dedicated.runner.assert_services_disabled_inventory_consistency_from_env",
|
|
side_effect=ServicesDisabledConflictError("boom"),
|
|
),
|
|
self.assertRaises(SystemExit) as ctx,
|
|
):
|
|
runner.run_ansible_playbook(
|
|
repo_root="/repo",
|
|
playbook_path="/repo/playbook.yml",
|
|
inventory_validator_path="/repo/cli/administration/inventory/validate/__main__.py",
|
|
inventory="/etc/inventories/github-ci/devices.yml",
|
|
modes={
|
|
"MODE_CLEANUP": False,
|
|
"MODE_ASSERT": True,
|
|
"MODE_DEBUG": False,
|
|
},
|
|
limit=None,
|
|
allowed_applications=None,
|
|
password_file=None,
|
|
verbose=0,
|
|
skip_build=True,
|
|
diff=False,
|
|
ansible_args=None,
|
|
)
|
|
|
|
self.assertEqual(ctx.exception.code, 1)
|
|
self.assertFalse(
|
|
any(call_cmd and call_cmd[0] == "ansible-playbook" for call_cmd, _ in calls)
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|