Files
FabledSteward/steward/ansible/executor.py
T
bvandeusen 88ab5b917e chore: rename project Roundtable → Steward
Renames the Python package directory, CLI command, env var prefix,
docker-compose service/container/image, Postgres role/db, and all
visible branding. Marketing form is "Fabled Steward".

Clean break from the previous rebrand: drops the fabledscryer→roundtable
import shim in __init__.py and the FABLEDSCRYER_* env var fallback in
config.py and migrations/env.py. Env vars are now STEWARD_* only.

Heads-up for existing deployments:
- Postgres user/db renamed fabledscryer → steward in docker-compose.yml.
  Existing volumes need the role/db renamed inside Postgres, or override
  POSTGRES_USER/POSTGRES_DB to keep the old names.
- Host-agent systemd unit is now steward-agent.service. Existing agents
  keep running under the old name; reinstall to switch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:20:14 -04:00

133 lines
4.0 KiB
Python

# steward/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 steward.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))