From 2fd9d331d69279beee709549f76d4144cf28bb90 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 17 Mar 2026 20:23:34 -0400 Subject: [PATCH] feat: add ansible executor with SSE broadcast and DB flush --- fablednetmon/ansible/executor.py | 132 +++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 fablednetmon/ansible/executor.py diff --git a/fablednetmon/ansible/executor.py b/fablednetmon/ansible/executor.py new file mode 100644 index 0000000..d59dc0a --- /dev/null +++ b/fablednetmon/ansible/executor.py @@ -0,0 +1,132 @@ +# fablednetmon/ansible/executor.py +from __future__ import annotations +import asyncio +import logging +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from quart import Quart + +logger = logging.getLogger(__name__) + +_OUTPUT_CAP_BYTES = 1024 * 1024 # 1 MB DB cap +_FLUSH_LINES = 50 +_FLUSH_SECS = 5.0 + + +@dataclass +class _Done: + status: str + + +# Per-run broadcast state (lives until process restarts) +_run_lines: dict[str, list[str]] = {} # all output lines received so far +_run_done: dict[str, _Done] = {} # set when run completes +_run_listeners: dict[str, list[asyncio.Queue]] = {} # active SSE client queues + + +def register_listener(run_id: str) -> asyncio.Queue: + """Create a listener queue for a run. Replays existing lines immediately.""" + q: asyncio.Queue = asyncio.Queue() + for line in _run_lines.get(run_id, []): + q.put_nowait(line) + done = _run_done.get(run_id) + if done: + q.put_nowait(done) + else: + _run_listeners.setdefault(run_id, []).append(q) + return q + + +def deregister_listener(run_id: str, q: asyncio.Queue) -> None: + listeners = _run_listeners.get(run_id, []) + if q in listeners: + listeners.remove(q) + + +def _broadcast(run_id: str, item: str | _Done) -> None: + if isinstance(item, str): + _run_lines.setdefault(run_id, []).append(item) + else: + _run_done[run_id] = item + for q in _run_listeners.get(run_id, []): + q.put_nowait(item) + if isinstance(item, _Done): + _run_listeners.pop(run_id, None) + + +async def start_run( + app: "Quart", + run_id: str, + playbook_path: str, + inventory_path: str, + source_path: str, +) -> None: + """Execute ansible-playbook as a subprocess and update the DB run row.""" + _run_lines[run_id] = [] + + db_output = "" + truncated = False + lines_since_flush = 0 + last_flush_at = time.monotonic() + + async def _flush(final_status: str | None = None) -> None: + nonlocal lines_since_flush, last_flush_at + from sqlalchemy import update + from fablednetmon.models.ansible import AnsibleRun, AnsibleRunStatus + + values: dict = {"output": db_output} + if final_status is not None: + values["status"] = AnsibleRunStatus(final_status) + values["finished_at"] = datetime.now(timezone.utc) + + async with app.db_sessionmaker() as session: + async with session.begin(): + await session.execute( + update(AnsibleRun).where(AnsibleRun.id == run_id).values(**values) + ) + lines_since_flush = 0 + last_flush_at = time.monotonic() + + cmd = ["ansible-playbook", playbook_path, "-i", inventory_path] + cwd = source_path if Path(source_path).exists() else None + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=cwd, + ) + assert proc.stdout is not None + + async for raw_line in proc.stdout: + line = raw_line.decode(errors="replace").rstrip("\n\r") + _broadcast(run_id, line) + + if not truncated: + candidate = (db_output + "\n" + line) if db_output else line + if len(candidate.encode()) > _OUTPUT_CAP_BYTES: + db_output += "\n[output truncated]" + truncated = True + else: + db_output = candidate + + lines_since_flush += 1 + elapsed = time.monotonic() - last_flush_at + if lines_since_flush >= _FLUSH_LINES or elapsed >= _FLUSH_SECS: + await _flush() + + await proc.wait() + final_status = "success" if proc.returncode == 0 else "failed" + + except Exception: + logger.exception("Ansible run %s failed with exception", run_id) + final_status = "failed" + + await _flush(final_status) + _broadcast(run_id, _Done(status=final_status))