refactor: rename package directory fabledscryer → roundtable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# fabledscryer/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 fabledscryer.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))
|
||||
@@ -0,0 +1,169 @@
|
||||
# fabledscryer/ansible/routes.py
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from quart import (
|
||||
Blueprint, Response, current_app, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from fabledscryer.ansible import executor, sources as src_module
|
||||
from fabledscryer.auth.middleware import require_role
|
||||
from fabledscryer.models.ansible import AnsibleRun, AnsibleRunStatus
|
||||
from fabledscryer.models.users import UserRole
|
||||
|
||||
ansible_bp = Blueprint("ansible", __name__, url_prefix="/ansible")
|
||||
|
||||
|
||||
def _get_sources() -> list[dict]:
|
||||
ansible_cfg = current_app.config.get("ANSIBLE", {})
|
||||
return src_module.get_sources(ansible_cfg)
|
||||
|
||||
|
||||
@ansible_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(
|
||||
select(AnsibleRun).order_by(AnsibleRun.started_at.desc()).limit(50)
|
||||
)
|
||||
runs = result.scalars().all()
|
||||
return await render_template("ansible/index.html", runs=runs)
|
||||
|
||||
|
||||
@ansible_bp.get("/browse")
|
||||
@require_role(UserRole.viewer)
|
||||
async def browse():
|
||||
all_sources = _get_sources()
|
||||
source_data = []
|
||||
for source in all_sources:
|
||||
playbooks = src_module.discover_playbooks(source["path"])
|
||||
inventories = src_module.discover_inventories(source["path"])
|
||||
source_data.append({
|
||||
"source": source,
|
||||
"playbooks": playbooks,
|
||||
"inventories": inventories,
|
||||
})
|
||||
return await render_template("ansible/browse.html", source_data=source_data)
|
||||
|
||||
|
||||
@ansible_bp.get("/browse/<source_name>/<path:playbook_path>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def view_playbook(source_name: str, playbook_path: str):
|
||||
all_sources = _get_sources()
|
||||
source = next((s for s in all_sources if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return "Source not found", 404
|
||||
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||
if contents is None:
|
||||
return "Playbook not found", 404
|
||||
return await render_template(
|
||||
"ansible/browse.html",
|
||||
source_data=[],
|
||||
view_source=source_name,
|
||||
view_path=playbook_path,
|
||||
view_contents=contents,
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.post("/runs")
|
||||
@require_role(UserRole.operator)
|
||||
async def create_run():
|
||||
form = await request.form
|
||||
playbook_path = form.get("playbook_path", "").strip()
|
||||
source_name = form.get("source_name", "").strip()
|
||||
inventory_path = form.get("inventory_path", "").strip()
|
||||
|
||||
if not playbook_path or not inventory_path:
|
||||
return "playbook_path and inventory_path are required", 400
|
||||
|
||||
all_sources = _get_sources()
|
||||
source = next((s for s in all_sources if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return "Source not found", 404
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
run = AnsibleRun(
|
||||
id=run_id,
|
||||
playbook_path=playbook_path,
|
||||
inventory_path=inventory_path,
|
||||
source_name=source_name,
|
||||
triggered_by=session["user_id"],
|
||||
status=AnsibleRunStatus.running,
|
||||
started_at=now,
|
||||
)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
db.add(run)
|
||||
|
||||
task = asyncio.create_task(
|
||||
executor.start_run(
|
||||
current_app._get_current_object(), # type: ignore[attr-defined]
|
||||
run_id,
|
||||
playbook_path,
|
||||
inventory_path,
|
||||
source["path"],
|
||||
)
|
||||
)
|
||||
task.add_done_callback(
|
||||
lambda t: t.exception() and current_app.logger.error(
|
||||
"Ansible run %s raised: %s", run_id, t.exception()
|
||||
)
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"ansible/run_started.html",
|
||||
run=run,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.get("/runs/<run_id>/stream")
|
||||
@require_role(UserRole.viewer)
|
||||
async def stream_run(run_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(AnsibleRun).where(AnsibleRun.id == run_id))
|
||||
run = result.scalar_one_or_none()
|
||||
if run is None:
|
||||
return "Not found", 404
|
||||
|
||||
async def generate():
|
||||
if run.status != AnsibleRunStatus.running:
|
||||
yield f"event: done\ndata: {run.status.value}\n\n"
|
||||
return
|
||||
|
||||
q = executor.register_listener(run_id)
|
||||
try:
|
||||
while True:
|
||||
msg = await q.get()
|
||||
if isinstance(msg, executor._Done):
|
||||
yield f"event: done\ndata: {msg.status}\n\n"
|
||||
break
|
||||
# Escape newlines in SSE data field
|
||||
safe = msg.replace("\n", " ")
|
||||
yield f"event: output\ndata: {safe}\n\n"
|
||||
finally:
|
||||
executor.deregister_listener(run_id, q)
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
content_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.get("/runs/<run_id>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def run_detail(run_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
result = await db.execute(select(AnsibleRun).where(AnsibleRun.id == run_id))
|
||||
run = result.scalar_one_or_none()
|
||||
if run is None:
|
||||
return "Not found", 404
|
||||
return await render_template("ansible/run_detail.html", run=run)
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INVENTORY_NAMES = {"hosts", "inventory", "inventory.yml", "inventory.ini"}
|
||||
|
||||
|
||||
def get_sources(ansible_cfg: dict) -> list[dict]:
|
||||
"""Return resolved source list from ansible config section.
|
||||
|
||||
Each source dict has: name, type, path (resolved local path),
|
||||
url (git only), branch (git only), pull_interval_seconds (git only).
|
||||
|
||||
Config structure in config.yaml::
|
||||
|
||||
ansible:
|
||||
cache_dir: /var/cache/fabledscryer/ansible
|
||||
sources:
|
||||
- name: my-playbooks
|
||||
type: local
|
||||
path: /opt/playbooks
|
||||
- name: infra-repo
|
||||
type: git
|
||||
url: https://github.com/user/infra.git
|
||||
branch: main
|
||||
pull_interval_seconds: 3600
|
||||
"""
|
||||
sources = ansible_cfg.get("sources", [])
|
||||
cache_dir = ansible_cfg.get("cache_dir", "/var/cache/fabledscryer/ansible")
|
||||
result = []
|
||||
for src in sources:
|
||||
src_type = src.get("type", "local")
|
||||
if src_type == "git":
|
||||
if not src.get("url"):
|
||||
raise ValueError(f"Ansible git source {src['name']!r} is missing required 'url' field")
|
||||
path = str(Path(cache_dir) / src["name"])
|
||||
else:
|
||||
if not src.get("path"):
|
||||
raise ValueError(f"Ansible local source {src['name']!r} is missing required 'path' field")
|
||||
path = src.get("path", "")
|
||||
result.append({
|
||||
"name": src["name"],
|
||||
"type": src_type,
|
||||
"path": path,
|
||||
"url": src.get("url"),
|
||||
"branch": src.get("branch", "main"),
|
||||
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def discover_playbooks(source_path: str) -> list[str]:
|
||||
"""Recursively find .yml and .yaml files in source_path. Returns relative paths."""
|
||||
root = Path(source_path)
|
||||
if not root.exists():
|
||||
return []
|
||||
playbooks = set()
|
||||
for ext in ("*.yml", "*.yaml"):
|
||||
for p in root.rglob(ext):
|
||||
playbooks.add(str(p.relative_to(root)))
|
||||
return sorted(playbooks)
|
||||
|
||||
|
||||
def discover_inventories(source_path: str) -> list[str]:
|
||||
"""Non-recursive: return inventory filenames present in root of source_path."""
|
||||
root = Path(source_path)
|
||||
if not root.exists():
|
||||
return []
|
||||
return sorted(name for name in INVENTORY_NAMES if (root / name).exists())
|
||||
|
||||
|
||||
def read_playbook(source_path: str, relative_path: str) -> str | None:
|
||||
"""Return contents of a playbook file, or None if not found / path escape."""
|
||||
root = Path(source_path).resolve()
|
||||
target = (root / relative_path).resolve()
|
||||
# Guard against path traversal
|
||||
try:
|
||||
target.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
if not target.exists() or not target.is_file():
|
||||
return None
|
||||
return target.read_text(errors="replace")
|
||||
|
||||
|
||||
async def git_pull(source: dict) -> None:
|
||||
"""Clone the git repo if absent; pull if already present."""
|
||||
path = Path(source["path"])
|
||||
if not (path / ".git").exists():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "clone",
|
||||
"--branch", source["branch"],
|
||||
"--single-branch",
|
||||
source["url"], str(path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
logger.error("git clone failed for %r: %s", source["name"], stderr.decode(errors="replace"))
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "-C", str(path), "pull",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
logger.error("git pull failed for %r: %s", source["name"], stderr.decode(errors="replace"))
|
||||
Reference in New Issue
Block a user