"""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