Files
FabledSteward/steward/ansible/routes.py
T
bvandeusen c10eae1c74
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 1m6s
feat(ansible): in-app playbook authoring/editor
Adds create/edit/delete of playbooks from the UI (admin only), so a homelab
user without a git workflow can author automation in-app. A new always-present
writable local source "steward-local" (/data/ansible/playbooks, env-overridable,
created on first save) is editable alongside operator local-dir sources; the
bundled and git sources stay read-only (git is GitOps, clobbered on pull).

sources.py: write_playbook / delete_playbook (traversal-guarded, .yml/.yaml
only) + validate_playbook_yaml (YAML + play-list check) + is_editable_source.
routes.py: /playbooks/new, /edit, /save, /delete (admin). Browse gains a
"New playbook" button and per-playbook + view-page Edit/Delete for editable
sources. Plain textarea editor with save-time YAML validation. Unit tests for
write/delete/guard/validate.

Task #579 — completes milestone #37 (Ansible automation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:39:47 -04:00

458 lines
16 KiB
Python

# steward/ansible/routes.py
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from quart import (
Blueprint, Response, current_app, redirect, render_template,
request, session, url_for,
)
from sqlalchemy import select
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.ansible_schedule import AnsibleSchedule
from steward.models.users import User, UserRole
INTERVAL_PRESETS = [
(300, "Every 5 minutes"),
(3600, "Hourly"),
(21600, "Every 6 hours"),
(43200, "Every 12 hours"),
(86400, "Daily"),
(604800, "Weekly"),
]
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():
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
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,
"editable": src_module.is_editable_source(source),
})
async with current_app.db_sessionmaker() as db:
targets = (await db.execute(
select(AnsibleTarget).order_by(AnsibleTarget.name)
)).scalars().all()
groups = (await db.execute(
select(AnsibleGroup).order_by(AnsibleGroup.name)
)).scalars().all()
return await render_template(
"ansible/browse.html",
source_data=source_data,
targets=targets,
groups=groups,
)
@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,
view_editable=src_module.is_editable_source(source),
)
def _parse_run_params(form) -> tuple[dict | None, str | None]:
"""Build the executor params dict from form fields. Returns (params, error)."""
extra_vars: list[str] = []
for line in (form.get("extra_vars", "") or "").splitlines():
line = line.strip()
if not line:
continue
if "=" not in line:
return None, f"Invalid extra var (expected key=value): {line!r}"
extra_vars.append(line)
params: dict = {}
if extra_vars:
params["extra_vars"] = extra_vars
limit = (form.get("limit", "") or "").strip()
if limit:
params["limit"] = limit
tags = (form.get("tags", "") or "").strip()
if tags:
params["tags"] = tags
if "check" in form:
params["check"] = True
return (params or None), None
@ansible_bp.post("/runs")
@require_role(UserRole.operator)
async def create_run():
from steward.ansible.runner import trigger_run
form = await request.form
playbook_path = form.get("playbook_path", "").strip()
source_name = form.get("source_name", "").strip()
inventory_scope = form.get("inventory_scope", "steward:all").strip()
if not playbook_path:
return "playbook_path is required", 400
params_or_none, err = _parse_run_params(form)
if err:
return err, 400
run, source, err = await trigger_run(
current_app._get_current_object(), # type: ignore[attr-defined]
source_name=source_name,
playbook_path=playbook_path,
inventory_scope=inventory_scope,
params=params_or_none,
triggered_by=session["user_id"],
)
if err == "Source not found":
return err, 404
if err:
return err, 400
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
# NULL triggered_by = an automated (system) run; otherwise resolve the user.
triggered_label = "system"
if run.triggered_by:
res = await db.execute(
select(User.username).where(User.id == run.triggered_by))
triggered_label = res.scalar_one_or_none() or "(deleted user)"
return await render_template(
"ansible/run_detail.html", run=run, triggered_label=triggered_label)
# ── Scheduled recurring runs ──────────────────────────────────────────────────
@ansible_bp.get("/schedules")
@require_role(UserRole.viewer)
async def schedules():
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
editing_id = request.args.get("edit")
async with current_app.db_sessionmaker() as db:
scheds = (await db.execute(
select(AnsibleSchedule).order_by(AnsibleSchedule.name))).scalars().all()
groups = (await db.execute(
select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
targets = (await db.execute(
select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all()
run_ids = [s.last_run_id for s in scheds if s.last_run_id]
runs = {
r.id: r for r in (await db.execute(
select(AnsibleRun).where(AnsibleRun.id.in_(run_ids)))).scalars().all()
} if run_ids else {}
source_data = [
{"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])}
for s in _get_sources()
]
all_playbooks = sorted({p for sd in source_data for p in sd["playbooks"]})
rows = []
editing = None
for s in scheds:
rows.append({
"s": s,
"last_run": runs.get(s.last_run_id) if s.last_run_id else None,
"next_run": (s.last_run_at + timedelta(seconds=s.interval_seconds)) if s.last_run_at else None,
})
if editing_id and s.id == editing_id:
editing = s
return await render_template(
"ansible/schedules.html",
rows=rows, groups=groups, targets=targets,
source_data=source_data, all_playbooks=all_playbooks,
editing=editing, interval_presets=INTERVAL_PRESETS,
)
def _schedule_form_fields(form) -> tuple[dict | None, str | None]:
"""Validate + extract schedule fields from a form. Returns (fields, error)."""
name = (form.get("name", "") or "").strip()
source_name = (form.get("source_name", "") or "").strip()
playbook_path = (form.get("playbook_path", "") or "").strip()
inventory_scope = (form.get("inventory_scope", "steward:all") or "steward:all").strip()
try:
interval = int(form.get("interval_seconds", "0"))
except (TypeError, ValueError):
interval = 0
if not name or not source_name or not playbook_path or interval <= 0:
return None, "name, source, playbook, and a positive interval are required"
params, err = _parse_run_params(form)
if err:
return None, err
return {
"name": name, "source_name": source_name, "playbook_path": playbook_path,
"inventory_scope": inventory_scope, "interval_seconds": interval, "params": params,
}, None
@ansible_bp.post("/schedules")
@require_role(UserRole.operator)
async def create_schedule():
form = await request.form
fields, err = _schedule_form_fields(form)
if err:
return err, 400
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(AnsibleSchedule(enabled=True, **fields))
return redirect(url_for("ansible.schedules"))
@ansible_bp.post("/schedules/<schedule_id>")
@require_role(UserRole.operator)
async def update_schedule(schedule_id: str):
form = await request.form
fields, err = _schedule_form_fields(form)
if err:
return err, 400
async with current_app.db_sessionmaker() as db:
async with db.begin():
sched = await db.get(AnsibleSchedule, schedule_id)
if sched is None:
return "Not found", 404
for k, v in fields.items():
setattr(sched, k, v)
return redirect(url_for("ansible.schedules"))
@ansible_bp.post("/schedules/<schedule_id>/toggle")
@require_role(UserRole.operator)
async def toggle_schedule(schedule_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
sched = await db.get(AnsibleSchedule, schedule_id)
if sched is None:
return "Not found", 404
sched.enabled = not sched.enabled
return redirect(url_for("ansible.schedules"))
@ansible_bp.post("/schedules/<schedule_id>/delete")
@require_role(UserRole.operator)
async def delete_schedule(schedule_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
sched = await db.get(AnsibleSchedule, schedule_id)
if sched is not None:
await db.delete(sched)
return redirect(url_for("ansible.schedules"))
@ansible_bp.post("/schedules/<schedule_id>/run-now")
@require_role(UserRole.operator)
async def run_schedule_now(schedule_id: str):
from steward.ansible.runner import trigger_run
async with current_app.db_sessionmaker() as db:
sched = await db.get(AnsibleSchedule, schedule_id)
if sched is None:
return "Not found", 404
source_name, playbook_path = sched.source_name, sched.playbook_path
inventory_scope, params = sched.inventory_scope, sched.params
run, _source, err = await trigger_run(
current_app._get_current_object(), # type: ignore[attr-defined]
source_name=source_name, playbook_path=playbook_path,
inventory_scope=inventory_scope, params=params,
triggered_by=session["user_id"],
)
async with current_app.db_sessionmaker() as db:
async with db.begin():
fresh = await db.get(AnsibleSchedule, schedule_id)
if fresh:
fresh.last_run_at = datetime.now(timezone.utc)
fresh.last_run_id = run.id if run else None
fresh.last_error = err
if run:
return redirect(url_for("ansible.run_detail", run_id=run.id))
return redirect(url_for("ansible.schedules"))
# ── In-app playbook authoring/editor (admin only) ─────────────────────────────
def _editable_sources() -> list[dict]:
return [s for s in _get_sources() if src_module.is_editable_source(s)]
def _editable_source(name: str) -> dict | None:
return next((s for s in _editable_sources() if s["name"] == name), None)
@ansible_bp.get("/playbooks/new")
@require_role(UserRole.admin)
async def new_playbook():
sources = _editable_sources()
return await render_template(
"ansible/playbook_editor.html",
editable_sources=sources, editing=False,
source_name=(sources[0]["name"] if sources else ""),
playbook_path="", content=_NEW_PLAYBOOK_TEMPLATE, error=None,
)
@ansible_bp.get("/playbooks/edit/<source_name>/<path:playbook_path>")
@require_role(UserRole.admin)
async def edit_playbook(source_name: str, playbook_path: str):
source = _editable_source(source_name)
if source is None:
return "Source is not editable", 404
content = src_module.read_playbook(source["path"], playbook_path)
if content is None:
return "Playbook not found", 404
return await render_template(
"ansible/playbook_editor.html",
editable_sources=_editable_sources(), editing=True,
source_name=source_name, playbook_path=playbook_path,
content=content, error=None,
)
@ansible_bp.post("/playbooks/save")
@require_role(UserRole.admin)
async def save_playbook():
form = await request.form
source_name = (form.get("source_name", "") or "").strip()
playbook_path = (form.get("playbook_path", "") or "").strip()
content = form.get("content", "") or ""
editing = (form.get("editing", "") == "1")
source = _editable_source(source_name)
err = None
if source is None:
err = "Source is not editable"
elif not playbook_path:
err = "Filename is required"
else:
ok, verr = src_module.validate_playbook_yaml(content)
if not ok:
err = verr
if err is None:
ok, werr = src_module.write_playbook(source["path"], playbook_path, content)
if not ok:
err = werr
if err is not None:
return await render_template(
"ansible/playbook_editor.html",
editable_sources=_editable_sources(), editing=editing,
source_name=source_name, playbook_path=playbook_path,
content=content, error=err,
), 400
return redirect(url_for(
"ansible.view_playbook", source_name=source_name, playbook_path=playbook_path))
@ansible_bp.post("/playbooks/delete")
@require_role(UserRole.admin)
async def delete_playbook():
form = await request.form
source = _editable_source((form.get("source_name", "") or "").strip())
if source is None:
return "Source is not editable", 400
ok, err = src_module.delete_playbook(source["path"], (form.get("playbook_path", "") or "").strip())
if not ok:
return err, 400
return redirect(url_for("ansible.browse"))
_NEW_PLAYBOOK_TEMPLATE = """\
---
- name: My playbook
hosts: all
gather_facts: false
tasks:
- name: Ping
ansible.builtin.ping:
"""