mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-11 13:17:07 +00:00
The runner now ships the execution modes as one JSON extra-var, so the old assertIn on MODE_CLEANUP=true no longer matches. Locate the last -e instead, parse its argument and compare the whole mapping: that catches a missing or extra mode, which the substring check could not, and it no longer passes when the text happens to appear inside an unrelated argument. Drop the narration comments the file carried; the assertions state the same thing. Carry the autoformat wrap on the AUR retry test. 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()
|