GRM-8: feat: add AnsibleExecutor and i18n modules

This commit is contained in:
Emil Simeonov
2026-06-18 20:16:50 +02:00
parent 6414f2306c
commit 564c12e782
3 changed files with 440 additions and 0 deletions
+90
View File
@@ -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
+193
View File
@@ -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)