Post-merge / detect-type (push) Successful in 51s
Post-merge / release (push) Successful in 1m14s
Post-merge / validate-commit-msg (push) Successful in 1m14s
Post-merge / vikunja (push) Successful in 1m14s
Post-merge / badges (push) Successful in 1m25s
Post-merge / sync-wiki (push) Successful in 1m53s
Post-merge / configure-repo (push) Successful in 1m20s
Post-merge / publish (push) Successful in 1m21s
312 lines
13 KiB
Python
312 lines
13 KiB
Python
"""Unit tests for AnsibleExecutor."""
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, mock_open, patch
|
|
|
|
import pytest
|
|
|
|
from gitea_runner_manager.exceptions import AnsibleError
|
|
from gitea_runner_manager.executor import AnsibleExecutor
|
|
|
|
|
|
def _mock_popen_process(returncode: int = 0) -> MagicMock:
|
|
"""Create a mock Popen process that yields no stdout and exits with given code."""
|
|
proc = MagicMock()
|
|
proc.stdout = iter([])
|
|
proc.wait.return_value = returncode
|
|
proc.returncode = returncode
|
|
return proc
|
|
|
|
|
|
class TestAnsibleExecutorRun:
|
|
def test_run_success(self, tmp_path: Path) -> None:
|
|
with patch("gitea_runner_manager.executor.say") as mock_say:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
|
|
mock_popen.return_value = _mock_popen_process(returncode=0)
|
|
executor.run(["echo", "hello"], description="Test run")
|
|
|
|
messages = [call.args[0] for call in mock_say.call_args_list]
|
|
assert any("Test run" in msg for msg in messages)
|
|
assert any("Done" in msg for msg in messages)
|
|
|
|
def test_run_failure(self, tmp_path: Path) -> None:
|
|
with patch("gitea_runner_manager.executor.say") as mock_say:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
|
|
mock_popen.return_value = _mock_popen_process(returncode=1)
|
|
with pytest.raises(AnsibleError, match="exit code 1"):
|
|
executor.run(["false"], description="Test run")
|
|
|
|
error_calls = [call for call in mock_say.call_args_list if call.kwargs.get("level") == logging.ERROR]
|
|
assert len(error_calls) == 1
|
|
assert "exit code 1" in error_calls[0].args[0]
|
|
|
|
def test_run_default_description(self, tmp_path: Path) -> None:
|
|
with patch("gitea_runner_manager.executor.say") as mock_say:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
|
|
mock_popen.return_value = _mock_popen_process(returncode=0)
|
|
executor.run(["echo", "hello"])
|
|
|
|
messages = [call.args[0] for call in mock_say.call_args_list]
|
|
assert any("Running Ansible playbook" in msg for msg in messages)
|
|
|
|
def test_run_with_stdout_lines(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
proc = MagicMock()
|
|
proc.stdout = iter(["line1\n", "line2\n"])
|
|
proc.wait.return_value = 0
|
|
proc.returncode = 0
|
|
mock_file = mock_open()
|
|
|
|
with patch("subprocess.Popen", return_value=proc), patch("builtins.open", mock_file):
|
|
executor.run(["echo", "hello"], description="Test run")
|
|
handle = mock_file()
|
|
handle.write.assert_any_call("line1\n")
|
|
handle.write.assert_any_call("line2\n")
|
|
|
|
def test_run_extracts_status(self, tmp_path: Path) -> None:
|
|
log_content = (
|
|
"TASK [gitea-runner : Report runner status]\n"
|
|
"ok: [127.0.0.1] => {\n"
|
|
' "msg": "Runner \'127.0.0.1\' is installed and running."\n'
|
|
"}\n"
|
|
)
|
|
with patch("gitea_runner_manager.executor.say") as mock_say:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open(read_data=log_content)):
|
|
mock_popen.return_value = _mock_popen_process(returncode=0)
|
|
executor.run(["echo", "hello"], description="Test run")
|
|
|
|
messages = [call.args[0] for call in mock_say.call_args_list]
|
|
assert any("Runner '127.0.0.1' is installed and running" in msg for msg in messages)
|
|
|
|
|
|
class TestAnsibleExecutorExtractStatus:
|
|
def test_extract_status_found(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor()
|
|
log_content = (
|
|
"TASK [gitea-runner : Report runner status]\n"
|
|
"ok: [127.0.0.1] => {\n"
|
|
' "msg": "Runner \'127.0.0.1\' is installed and running."\n'
|
|
"}\n"
|
|
)
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text(log_content)
|
|
status = executor._extract_status(log_file)
|
|
assert status is not None
|
|
assert "Runner '127.0.0.1' is installed and running" in status
|
|
|
|
def test_extract_status_not_found(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor()
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text('TASK [other]\nok: [host] => {\n "msg": "something else"\n}\n')
|
|
status = executor._extract_status(log_file)
|
|
assert status is None
|
|
|
|
def test_extract_status_exception(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor()
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text("incomplete")
|
|
with patch("gitea_runner_manager.executor.open", side_effect=OSError("read error")):
|
|
status = executor._extract_status(log_file)
|
|
assert status is None
|
|
|
|
|
|
class TestAnsibleExecutorPrepareLog:
|
|
def test_prepare_log(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
log_file = executor._prepare_log(["ansible-playbook", "test.yml"])
|
|
assert log_file.parent == tmp_path
|
|
assert log_file.name.startswith("ansible-")
|
|
content = log_file.read_text()
|
|
assert "=== GRM Ansible Run:" in content
|
|
assert "Command: ansible-playbook test.yml" in content
|
|
|
|
def test_prepare_log_creates_directories(self, tmp_path: Path) -> None:
|
|
nested = tmp_path / "a" / "b" / "c"
|
|
executor = AnsibleExecutor(log_dir=nested)
|
|
log_file = executor._prepare_log(["echo"])
|
|
assert nested.exists()
|
|
assert log_file.exists()
|
|
|
|
|
|
class TestAnsibleExecutorTranslation:
|
|
def test_run_translated(self, tmp_path: Path) -> None:
|
|
with (
|
|
patch.dict(os.environ, {"GRM_LANG": "de"}),
|
|
patch("gitea_runner_manager.executor.say") as mock_say,
|
|
):
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
|
|
mock_popen.return_value = _mock_popen_process(returncode=0)
|
|
executor.run(["echo", "hello"]) # use default description to test translation
|
|
|
|
messages = [call.args[0] for call in mock_say.call_args_list]
|
|
assert any("Ansible-Playbook wird ausgeführt" in msg for msg in messages)
|
|
assert any("Fertig" in msg for msg in messages)
|
|
|
|
def test_unsupported_lang_fallback(self, tmp_path: Path) -> None:
|
|
with (
|
|
patch.dict(os.environ, {"GRM_LANG": "xx"}),
|
|
patch("gitea_runner_manager.executor.say") as mock_say,
|
|
):
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
with patch("subprocess.Popen") as mock_popen, patch("builtins.open", mock_open()):
|
|
mock_popen.return_value = _mock_popen_process(returncode=0)
|
|
executor.run(["echo", "hello"])
|
|
|
|
messages = [call.args[0] for call in mock_say.call_args_list]
|
|
assert any("Running Ansible playbook" in msg for msg in messages) # English fallback
|
|
|
|
|
|
class TestAnsibleExecutorAdHoc:
|
|
def test_run_ad_hoc_success(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
result_mock = MagicMock()
|
|
result_mock.stdout = "active\n"
|
|
result_mock.returncode = 0
|
|
result_mock.stderr = ""
|
|
|
|
with patch("subprocess.run", return_value=result_mock):
|
|
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", "/key", "shell", "systemctl is-active svc")
|
|
|
|
assert result == "active"
|
|
|
|
def test_run_ad_hoc_without_key(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
result_mock = MagicMock()
|
|
result_mock.stdout = "inactive\n"
|
|
result_mock.returncode = 0
|
|
result_mock.stderr = ""
|
|
|
|
with patch("subprocess.run", return_value=result_mock) as mock_run:
|
|
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd")
|
|
|
|
assert result == "inactive"
|
|
cmd = mock_run.call_args.args[0]
|
|
assert "--private-key" not in cmd
|
|
|
|
def test_run_ad_hoc_with_become(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
result_mock = MagicMock()
|
|
result_mock.stdout = "ok\n"
|
|
result_mock.returncode = 0
|
|
result_mock.stderr = ""
|
|
|
|
with patch("subprocess.run", return_value=result_mock) as mock_run:
|
|
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd", become=True)
|
|
|
|
assert result == "ok"
|
|
cmd = mock_run.call_args.args[0]
|
|
assert "--become" in cmd
|
|
|
|
def test_run_ad_hoc_with_ask_become_pass(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
result_mock = MagicMock()
|
|
result_mock.stdout = "ok\n"
|
|
result_mock.returncode = 0
|
|
result_mock.stderr = ""
|
|
|
|
with patch("subprocess.run", return_value=result_mock) as mock_run:
|
|
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd", become=True, ask_become_pass=True)
|
|
|
|
assert result == "ok"
|
|
cmd = mock_run.call_args.args[0]
|
|
assert "--become" in cmd
|
|
assert "--ask-become-pass" in cmd
|
|
|
|
def test_run_ad_hoc_with_become_pass(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
result_mock = MagicMock()
|
|
result_mock.stdout = "ok\n"
|
|
result_mock.returncode = 0
|
|
result_mock.stderr = ""
|
|
|
|
with patch("subprocess.run", return_value=result_mock) as mock_run:
|
|
with patch("os.unlink"):
|
|
result = executor.run_ad_hoc(
|
|
"10.0.0.1",
|
|
"ubuntu",
|
|
None,
|
|
"shell",
|
|
"cmd",
|
|
become=True,
|
|
ask_become_pass=True,
|
|
become_pass="secret",
|
|
)
|
|
|
|
assert result == "ok"
|
|
cmd = mock_run.call_args.args[0]
|
|
assert "--become" in cmd
|
|
assert "--become-password-file" in cmd
|
|
assert "--ask-become-pass" not in cmd
|
|
|
|
def test_run_ad_hoc_ask_become_pass_no_become(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
result_mock = MagicMock()
|
|
result_mock.stdout = "ok\n"
|
|
result_mock.returncode = 0
|
|
result_mock.stderr = ""
|
|
|
|
with patch("subprocess.run", return_value=result_mock) as mock_run:
|
|
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd", become=False, ask_become_pass=True)
|
|
|
|
assert result == "ok"
|
|
cmd = mock_run.call_args.args[0]
|
|
assert "--become" not in cmd
|
|
assert "--ask-become-pass" not in cmd
|
|
|
|
def test_run_ad_hoc_with_env_become_password_file(self, tmp_path: Path) -> None:
|
|
"""ANSIBLE_BECOME_PASSWORD_FILE env var used when become_pass is None."""
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
result_mock = MagicMock()
|
|
result_mock.stdout = "ok\n"
|
|
result_mock.returncode = 0
|
|
result_mock.stderr = ""
|
|
|
|
with patch.dict("os.environ", {"ANSIBLE_BECOME_PASSWORD_FILE": "/tmp/env-pw.txt"}):
|
|
with patch("subprocess.run", return_value=result_mock) as mock_run:
|
|
result = executor.run_ad_hoc(
|
|
"10.0.0.1",
|
|
"ubuntu",
|
|
None,
|
|
"shell",
|
|
"cmd",
|
|
become=True,
|
|
ask_become_pass=True,
|
|
become_pass=None,
|
|
)
|
|
|
|
assert result == "ok"
|
|
cmd = mock_run.call_args.args[0]
|
|
assert "--become-password-file" in cmd
|
|
assert "/tmp/env-pw.txt" in cmd
|
|
assert "--ask-become-pass" not in cmd
|
|
|
|
def test_run_ad_hoc_check_false(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
result_mock = MagicMock()
|
|
result_mock.stdout = "inactive\n"
|
|
result_mock.returncode = 3
|
|
result_mock.stderr = ""
|
|
|
|
with patch("subprocess.run", return_value=result_mock):
|
|
result = executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "systemctl is-active svc", check=False)
|
|
|
|
assert result == "inactive"
|
|
|
|
def test_run_ad_hoc_failure_raises(self, tmp_path: Path) -> None:
|
|
executor = AnsibleExecutor(log_dir=tmp_path)
|
|
result_mock = MagicMock()
|
|
result_mock.stdout = ""
|
|
result_mock.returncode = 1
|
|
result_mock.stderr = "SSH timeout"
|
|
|
|
with patch("subprocess.run", return_value=result_mock):
|
|
with pytest.raises(AnsibleError, match="SSH timeout"):
|
|
executor.run_ad_hoc("10.0.0.1", "ubuntu", None, "shell", "cmd")
|