"""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") log_file.chmod(0o000) try: status = executor._extract_status(log_file) assert status is None finally: log_file.chmod(0o644) 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: with patch("sys.stdin.isatty", return_value=True): 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_ask_become_pass_no_tty(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("sys.stdin.isatty", return_value=False): 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" 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")