Files
grm/src/gitea_runner_manager/executor.py
T

143 lines
4.4 KiB
Python

"""Ansible execution with log capture."""
from __future__ import annotations
import logging
import os
import re
import subprocess # nosec B404
import sys
from datetime import datetime
from pathlib import Path
from .exceptions import AnsibleError
from .i18n import _
from .logging_config import get_logger
from .ui import say
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"
self._logger = get_logger()
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")
say(desc, color="cyan")
say(_("Full log: {log_file}", log_file=log_file))
returncode = self._stream(cmd, log_file)
if returncode != 0:
msg = _(
"Ansible failed with exit code {code}. See full log: {log_file}",
code=returncode,
log_file=log_file,
)
say(msg, level=logging.ERROR, err=True, color="red")
raise AnsibleError(msg)
status = self._extract_status(log_file)
if status:
say(status, color="yellow")
say(_("Done. See full log: {log_file}", log_file=log_file), color="green")
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( # nosec B603
cmd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
assert proc.stdout is not None # nosec B101
try:
for line in proc.stdout:
f.write(line)
f.flush()
finally:
proc.wait()
return proc.returncode
def run_ad_hoc(
self,
host: str,
user: str,
key: str | None,
module: str,
args: str,
become: bool = False,
ask_become_pass: bool = False,
check: bool = True,
) -> str:
"""Run an Ansible ad-hoc command and return stdout."""
cmd = [
"ansible",
host,
"-m",
module,
"-a",
args,
"-u",
user,
]
if key:
cmd.extend(["--private-key", key])
if become:
cmd.append("--become")
if become and ask_become_pass and sys.stdin.isatty():
cmd.append("--ask-become-pass")
env = os.environ.copy()
proc = subprocess.run( # nosec B603
cmd,
env=env,
capture_output=True,
text=True,
)
if check and proc.returncode != 0:
stderr = proc.stderr.strip() if proc.stderr else ""
raise AnsibleError(
_(
"Ad-hoc command failed on {host}: {stderr}",
host=host,
stderr=stderr or _("exit code {code}", code=proc.returncode),
)
)
return proc.stdout.strip()
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 OSError:
return None
return None