Files
FabledSteward/steward/ansible/routes.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

170 lines
5.3 KiB
Python

# steward/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 steward.ansible import executor, sources as src_module
from steward.auth.middleware import require_role
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.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)