diff --git a/src/gitea_runner_manager/executor.py b/src/gitea_runner_manager/executor.py new file mode 100644 index 0000000..3d59d71 --- /dev/null +++ b/src/gitea_runner_manager/executor.py @@ -0,0 +1,90 @@ +"""Ansible execution with log capture.""" + +from __future__ import annotations + +import os +import re +import subprocess +from datetime import datetime +from pathlib import Path + +from .exceptions import AnsibleError +from .i18n import _ + + +class AnsibleExecutor: + """Executes Ansible playbooks, streaming output to log files.""" + + def __init__(self, log_dir: Path | None = None) -> None: + self._log_dir = log_dir or Path.home() / ".local" / "state" / "grm" / "logs" + + def run(self, cmd: list[str], description: str = "") -> None: + """Run an Ansible command, redirecting output to a log file.""" + log_file = self._prepare_log(cmd) + desc = description or _("Running Ansible playbook") + + print(f"[GRM] {desc}") + print(f"[GRM] {_('Full log: {log_file}', log_file=log_file)}") + + returncode = self._stream(cmd, log_file) + + if returncode != 0: + raise AnsibleError( + _( + "Ansible failed with exit code {code}. See full log: {log_file}", + code=returncode, + log_file=log_file, + ) + ) + + status = self._extract_status(log_file) + if status: + print(f"[GRM] {status}") + + print(f"[GRM] {_('Done. See full log: {log_file}', log_file=log_file)}") + + def _prepare_log(self, cmd: list[str]) -> Path: + """Create a log file with header.""" + self._log_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + log_file = self._log_dir / f"ansible-{timestamp}.log" + with open(log_file, "w") as f: + f.write(f"=== GRM Ansible Run: {timestamp} ===\n") + f.write(_("Command: {cmd}", cmd=" ".join(cmd)) + "\n\n") + return log_file + + def _stream(self, cmd: list[str], log_file: Path) -> int: + """Execute command, streaming stdout+stderr to log file. Returns exit code.""" + env = os.environ.copy() + with open(log_file, "a") as f: + proc = subprocess.Popen( + cmd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + assert proc.stdout is not None + try: + for line in proc.stdout: + f.write(line) + f.flush() + finally: + proc.wait() + return proc.returncode + + def _extract_status(self, log_file: Path) -> str | None: + """Extract the runner status message from the Ansible log.""" + try: + with open(log_file) as f: + content = f.read() + match = re.search( + r'TASK \[gitea-runner : Report runner status\].*?"msg":\s*"([^"]+)"', + content, + re.DOTALL, + ) + if match: + return match.group(1).replace("\\n", " ").strip() + except Exception: + pass + return None diff --git a/src/gitea_runner_manager/i18n.py b/src/gitea_runner_manager/i18n.py new file mode 100644 index 0000000..d6b314d --- /dev/null +++ b/src/gitea_runner_manager/i18n.py @@ -0,0 +1,193 @@ +"""Simple i18n for GRM console messages. + +Set GRM_LANG environment variable to override the default English. +Supported: en, bg, de, ru, zh. +""" + +from __future__ import annotations + +import os + +TRANSLATIONS: dict[str, dict[str, str]] = { + "Installing Gitea Runner on {host}": { + "en": "Installing Gitea Runner on {host}", + "bg": "Инсталиране на Gitea Runner на {host}", + "de": "Gitea Runner wird auf {host} installiert", + "ru": "Установка Gitea Runner на {host}", + "zh": "正在 {host} 上安装 Gitea Runner", + }, + "Updating Gitea Runner on {host}": { + "en": "Updating Gitea Runner on {host}", + "bg": "Актуализиране на Gitea Runner на {host}", + "de": "Gitea Runner wird auf {host} aktualisiert", + "ru": "Обновление Gitea Runner на {host}", + "zh": "正在 {host} 上更新 Gitea Runner", + }, + "Running Ansible playbook": { + "en": "Running Ansible playbook", + "bg": "Изпълнение на Ansible playbook", + "de": "Ansible-Playbook wird ausgeführt", + "ru": "Выполнение Ansible playbook", + "zh": "正在运行 Ansible playbook", + }, + "Full log: {log_file}": { + "en": "Full log: {log_file}", + "bg": "Пълен лог: {log_file}", + "de": "Vollständiges Log: {log_file}", + "ru": "Полный лог: {log_file}", + "zh": "完整日志: {log_file}", + }, + "Done. See full log: {log_file}": { + "en": "Done. See full log: {log_file}", + "bg": "Готово. Вижте пълния лог: {log_file}", + "de": "Fertig. Siehe vollständiges Log: {log_file}", + "ru": "Готово. См. полный лог: {log_file}", + "zh": "完成。查看完整日志: {log_file}", + }, + "Ansible failed with exit code {code}. See full log: {log_file}": { + "en": "Ansible failed with exit code {code}. See full log: {log_file}", + "bg": "Ansible е неуспешен с код {code}. Вижте пълния лог: {log_file}", + "de": "Ansible fehlgeschlagen mit Exit-Code {code}. Siehe Log: {log_file}", + "ru": "Ansible завершился с кодом {code}. См. лог: {log_file}", + "zh": "Ansible 失败,退出码 {code}。查看日志: {log_file}", + }, + "GITEA_REGISTRATION_TOKEN must be set (or pass --token)": { + "en": "GITEA_REGISTRATION_TOKEN must be set (or pass --token)", + "bg": "GITEA_REGISTRATION_TOKEN трябва да е зададен (или подайте --token)", + "de": "GITEA_REGISTRATION_TOKEN muss gesetzt sein (oder --token übergeben)", + "ru": "GITEA_REGISTRATION_TOKEN должен быть задан (или передайте --token)", + "zh": "必须设置 GITEA_REGISTRATION_TOKEN(或传递 --token)", + }, + "Playbook not found: {playbook}": { + "en": "Playbook not found: {playbook}", + "bg": "Playbook не е намерен: {playbook}", + "de": "Playbook nicht gefunden: {playbook}", + "ru": "Playbook не найден: {playbook}", + "zh": "未找到 Playbook: {playbook}", + }, + "GITEA_URL must be set (or pass --url)": { + "en": "GITEA_URL must be set (or pass --url)", + "bg": "GITEA_URL трябва да е зададен (или подайте --url)", + "de": "GITEA_URL muss gesetzt sein (oder --url übergeben)", + "ru": "GITEA_URL должен быть задан (или передайте --url)", + "zh": "必须设置 GITEA_URL(或传递 --url)", + }, + "Installation failed: {error}": { + "en": "Installation failed: {error}", + "bg": "Инсталацията неуспешна: {error}", + "de": "Installation fehlgeschlagen: {error}", + "ru": "Установка не удалась: {error}", + "zh": "安装失败: {error}", + }, + "Update failed: {error}": { + "en": "Update failed: {error}", + "bg": "Актуализацията неуспешна: {error}", + "de": "Update fehlgeschlagen: {error}", + "ru": "Обновление не удалось: {error}", + "zh": "更新失败: {error}", + }, + "SSH user": { + "en": "SSH user", + "bg": "SSH потребител", + "de": "SSH-Benutzer", + "ru": "SSH пользователь", + "zh": "SSH 用户", + }, + "Path to SSH private key": { + "en": "Path to SSH private key", + "bg": "Път към SSH частен ключ", + "de": "Pfad zum SSH-Private-Key", + "ru": "Путь к SSH приватному ключу", + "zh": "SSH 私钥路径", + }, + "Gitea Runner name (default: host)": { + "en": "Gitea Runner name (default: host)", + "bg": "Име на Gitea Runner (по подразбиране: host)", + "de": "Gitea Runner-Name (Standard: host)", + "ru": "Имя Gitea Runner (по умолчанию: host)", + "zh": "Gitea Runner 名称(默认: host)", + }, + "Registration token (env: GITEA_REGISTRATION_TOKEN)": { + "en": "Registration token (env: GITEA_REGISTRATION_TOKEN)", + "bg": "Регистрационен токен (env: GITEA_REGISTRATION_TOKEN)", + "de": "Registrierungstoken (env: GITEA_REGISTRATION_TOKEN)", + "ru": "Токен регистрации (env: GITEA_REGISTRATION_TOKEN)", + "zh": "注册令牌(环境变量: GITEA_REGISTRATION_TOKEN)", + }, + "Gitea Runner deployment mode (default: docker)": { + "en": "Gitea Runner deployment mode (default: docker)", + "bg": "Режим на разполагане на Gitea Runner (по подразбиране: docker)", + "de": "Gitea Runner-Bereitstellungsmodus (Standard: docker)", + "ru": "Режим развёртывания Gitea Runner (по умолчанию: docker)", + "zh": "Gitea Runner 部署模式(默认: docker)", + }, + "Gitea admin API token for integration test (env: GITEA_ADMIN_TOKEN)": { + "en": "Gitea admin API token for integration test (env: GITEA_ADMIN_TOKEN)", + "bg": "Gitea admin API токен за интеграционен тест (env: GITEA_ADMIN_TOKEN)", + "de": "Gitea-Admin-API-Token für Integrationstest (env: GITEA_ADMIN_TOKEN)", + "ru": "Токен админ API Gitea для интеграционного теста (env: GITEA_ADMIN_TOKEN)", + "zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: GITEA_ADMIN_TOKEN)", + }, + "Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)": { + "en": "Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)", + "bg": "Повторни опити за интеграционен тест API (по подразбиране: 3, env: GITEA_INTEGRATION_RETRIES)", + "de": "API-Wiederholungen für Integrationstest (Standard: 3, env: GITEA_INTEGRATION_RETRIES)", + "ru": "Повторы API интеграционного теста (по умолчанию: 3, env: GITEA_INTEGRATION_RETRIES)", + "zh": "集成测试 API 重试次数(默认: 3,环境变量: GITEA_INTEGRATION_RETRIES)", + }, + "Prompt for sudo password": { + "en": "Prompt for sudo password", + "bg": "Подканване за sudo парола", + "de": "Nach sudo-Passwort fragen", + "ru": "Запросить пароль sudo", + "zh": "提示输入 sudo 密码", + }, + "Specific Gitea Runner version": { + "en": "Specific Gitea Runner version", + "bg": "Конкретна версия на Gitea Runner", + "de": "Spezifische Gitea Runner-Version", + "ru": "Конкретная версия Gitea Runner", + "zh": "指定 Gitea Runner 版本", + }, + "Install and configure a Gitea Runner on a remote host.": { + "en": "Install and configure a Gitea Runner on a remote host.", + "bg": "Инсталиране и конфигуриране на Gitea Runner на отдалечен хост.", + "de": "Gitea Runner auf einem Remote-Host installieren und konfigurieren.", + "ru": "Установить и настроить Gitea Runner на удалённом хосте.", + "zh": "在远程主机上安装并配置 Gitea Runner。", + }, + "Update the Gitea Runner binary on a remote host.": { + "en": "Update the Gitea Runner binary on a remote host.", + "bg": "Актуализиране на Gitea Runner двоичния файл на отдалечен хост.", + "de": "Gitea Runner-Binary auf einem Remote-Host aktualisieren.", + "ru": "Обновить бинарный файл Gitea Runner на удалённом хосте.", + "zh": "在远程主机上更新 Gitea Runner 二进制文件。", + }, + "Gitea Runner Manager — manage Gitea Actions runners.": { + "en": "Gitea Runner Manager — manage Gitea Actions runners.", + "bg": "Gitea Runner Manager — управление на Gitea Actions runners.", + "de": "Gitea Runner Manager — Gitea Actions Runner verwalten.", + "ru": "Gitea Runner Manager — управление Gitea Actions runners.", + "zh": "Gitea Runner Manager — 管理 Gitea Actions runners。", + }, + "Command: {cmd}": { + "en": "Command: {cmd}", + "bg": "Команда: {cmd}", + "de": "Befehl: {cmd}", + "ru": "Команда: {cmd}", + "zh": "命令: {cmd}", + }, +} + + +def _(key: str, **kwargs: object) -> str: + """Return a translated string for the given key. + + Translation is opt-in via the ``GRM_LANG`` environment variable. + If unset, English is always returned regardless of system locale. + """ + lang = os.getenv("GRM_LANG", "en") + if lang not in ("en", "bg", "de", "ru", "zh"): + lang = "en" + template = TRANSLATIONS.get(key, {}).get(lang, key) + return template.format(**kwargs) diff --git a/tests/unit/test_executor.py b/tests/unit/test_executor.py new file mode 100644 index 0000000..0835b78 --- /dev/null +++ b/tests/unit/test_executor.py @@ -0,0 +1,157 @@ +"""Unit tests for AnsibleExecutor.""" + +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, capsys: pytest.CaptureFixture[str]) -> None: + 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") + + captured = capsys.readouterr() + assert "[GRM] Test run" in captured.out + assert "[GRM] Done" in captured.out + + def test_run_failure(self, tmp_path: Path) -> None: + 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") + + def test_run_default_description(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + 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"]) + + captured = capsys.readouterr() + assert "[GRM] Running Ansible playbook" in captured.out + + 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, capsys: pytest.CaptureFixture[str]) -> None: + executor = AnsibleExecutor(log_dir=tmp_path) + 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("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") + + captured = capsys.readouterr() + assert "Runner '127.0.0.1' is installed and running" in captured.out + + +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, capsys: pytest.CaptureFixture[str]) -> None: + executor = AnsibleExecutor(log_dir=tmp_path) + with ( + patch.dict(os.environ, {"GRM_LANG": "de"}), + 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 + + captured = capsys.readouterr() + assert "Ansible-Playbook wird ausgeführt" in captured.out + assert "Fertig" in captured.out + + def test_unsupported_lang_fallback(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + executor = AnsibleExecutor(log_dir=tmp_path) + with ( + patch.dict(os.environ, {"GRM_LANG": "xx"}), + patch("subprocess.Popen") as mock_popen, + patch("builtins.open", mock_open()), + ): + mock_popen.return_value = _mock_popen_process(returncode=0) + executor.run(["echo", "hello"]) + + captured = capsys.readouterr() + assert "[GRM] Running Ansible playbook" in captured.out # English fallback