refactor: rename package directory fabledscryer → roundtable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:10:31 -04:00
parent fd9c6d941d
commit 8aad2ab43d
117 changed files with 0 additions and 0 deletions
View File
+132
View File
@@ -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))
+169
View File
@@ -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)
+113
View File
@@ -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"))