143 lines
4.4 KiB
Python
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
|