Files
FabledSteward/steward/ansible/routes.py
T
bvandeusenandClaude Opus 5.5 4a33046aef
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m51s
CI / integration (push) Successful in 1m45s
CI / publish (push) Successful in 2m21s
fix(ansible): Run now runs the playbook and leaves the schedule's time alone (#5445)
Run now set last_run_at to the click, and a schedule's next turn counts
from last_run_at, so a manual run of the nightly prune at 14:00 moved
every later run to 14:00. Operator ruling: "run now shouldn't change the
schedule it should just run the playbook and leave the schedule as it
was."

Run now no longer touches last_run_at. It still records the run as
last_run_id, so the list shows it and a firing during it is skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:08:18 -04:00

867 lines
37 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 func, select
from steward.ansible import executor, guards, sources as src_module
from steward.ansible.inventory_gen import run_title, scope_labels
from steward.ansible.origins import run_origins
from steward.ansible.recap import host_recap, run_recap
from steward.auth.middleware import require_role
from steward.core.audit import audit_request as _audit
from steward.core.paging import keyset, next_page
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.ansible_schedule import AnsibleSchedule
from steward.models.users import 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)
# The runs list's filters (#5329): what each status choice and origin choice
# covers. A run started on the web pages has no origin and a user.
RUN_STATUSES = {
"failed": ("Failed", (AnsibleRunStatus.failed, AnsibleRunStatus.interrupted)),
"success": ("Succeeded", (AnsibleRunStatus.success,)),
"running": ("Running", (AnsibleRunStatus.running, AnsibleRunStatus.queued)),
"cancelled": ("Cancelled", (AnsibleRunStatus.cancelled,)),
}
RUN_ORIGINS = {"schedule": "A schedule", "alert": "An alert rule", "mcp": "An AI agent", "manual": "A person"}
VERDICT_DAYS = 30
def run_filters(args) -> dict:
"""The runs list's filters from the query string, unknown values dropped."""
status = args.get("status", "")
origin = args.get("origin", "")
return {"q": (args.get("q") or "").strip()[:200],
"status": status if status in RUN_STATUSES else "",
"origin": origin if origin in RUN_ORIGINS else ""}
def filter_runs(stmt, f: dict):
"""`stmt` narrowed to the runs the filters choose."""
if f["q"]:
stmt = stmt.where(func.lower(AnsibleRun.playbook_path).contains(f["q"].lower(), autoescape=True))
if f["status"]:
stmt = stmt.where(AnsibleRun.status.in_(RUN_STATUSES[f["status"]][1]))
if f["origin"] == "manual":
stmt = stmt.where(AnsibleRun.origin.is_(None), AnsibleRun.triggered_by.is_not(None))
elif f["origin"]:
stmt = stmt.where(AnsibleRun.origin == f["origin"])
return stmt
def next_scheduled(schedules, now: datetime) -> dict | None:
"""The enabled schedule due soonest: {"id", "name", "at"}, `at` None when it is
due now (it has never run, or its turn has passed)."""
best = None
for s in schedules:
if not s.enabled:
continue
at = s.last_run_at + timedelta(seconds=s.interval_seconds) if s.last_run_at else None
if at is not None and at <= now:
at = None
key = at or now
if best is None or key < best[0]:
best = (key, {"id": s.id, "name": s.name, "at": at})
return best[1] if best else None
async def runs_verdict(db, now: datetime) -> dict:
"""What the Runs page opens on: runs in the last VERDICT_DAYS days, how
many failed and the newest of those, how many are going now, and the
schedule due next."""
since = now - timedelta(days=VERDICT_DAYS)
bad = RUN_STATUSES["failed"][1]
going = RUN_STATUSES["running"][1]
total, failed, running = (await db.execute(select(
func.count(),
func.count().filter(AnsibleRun.status.in_(bad)),
func.count().filter(AnsibleRun.status.in_(going)),
).select_from(AnsibleRun).where(AnsibleRun.started_at >= since))).one()
latest_failed = None
if failed:
run = (await db.execute(select(AnsibleRun).where(
AnsibleRun.started_at >= since, AnsibleRun.status.in_(bad))
.order_by(AnsibleRun.started_at.desc()).limit(1))).scalar_one()
labels = await scope_labels(db, [run.inventory_scope])
latest_failed = run_view(run, labels, now)
schedules = (await db.execute(select(AnsibleSchedule))).scalars().all()
return {"days": VERDICT_DAYS, "total": total, "failed": failed, "running": running,
"latest_failed": latest_failed, "next": next_scheduled(schedules, now)}
@ansible_bp.get("/")
@require_role(UserRole.viewer)
async def index():
"""Every run, newest first, a page at a time as the reader scrolls, under
a verdict line and a filter bar (#5329). ?before=<cursor> is the next
page's rows alone (core.paging); an HTMX request from the filter bar is
the table alone."""
from urllib.parse import urlencode
f = run_filters(request.args)
before = request.args.get("before")
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
stmt = keyset(filter_runs(select(AnsibleRun), f), AnsibleRun.started_at, AnsibleRun.id, before)
runs, after = next_page((await db.execute(stmt)).scalars().all(),
lambda r: (r.started_at, r.id))
labels = await scope_labels(db, [r.inventory_scope for r in runs])
origins = await run_origins(db, runs)
verdict = None if before or request.headers.get("HX-Request") else await runs_verdict(db, now)
kept = {k: v for k, v in f.items() if v}
ctx = {"runs": [{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs],
"more_url": "/ansible/?" + urlencode({**kept, "before": after}) if after else None,
"f": f, "filtered": bool(kept), "statuses": RUN_STATUSES, "origins": RUN_ORIGINS,
"verdict": verdict}
if before:
return await render_template("ansible/_runs_page.html", **ctx)
if request.headers.get("HX-Request"):
resp = await current_app.make_response(await render_template("ansible/_runs_table.html", **ctx))
else:
resp = await current_app.make_response(await render_template("ansible/index.html", **ctx))
resp.vary.add("HX-Request")
return resp
def run_duration(run: AnsibleRun, now: datetime) -> float | None:
"""Seconds a run took, or has taken so far while it is still going."""
if run.started_at is None:
return None
return ((run.finished_at or now) - run.started_at).total_seconds()
def run_view(run: AnsibleRun, labels: dict[str, str], now: datetime) -> dict:
"""A run as the pages name it: its title, scope label, duration and what
it did across its hosts in words (#5329)."""
label = labels.get(run.inventory_scope, run.inventory_scope)
check = bool((run.params or {}).get("check"))
return {"run": run, "title": run_title(run.playbook_path, label), "scope_label": label,
"duration_s": run_duration(run, now), "check": check,
"recap": run_recap(run.results, check)}
def _source(name: str) -> dict | None:
return next((s for s in _get_sources() if s["name"] == name), None)
async def last_runs(db, keys) -> dict[tuple[str, str], AnsibleRun]:
"""{(source, playbook): its newest run} for the given playbooks."""
keys = list(keys)
if not keys:
return {}
newest = (select(AnsibleRun.source_name, AnsibleRun.playbook_path,
func.max(AnsibleRun.started_at).label("at"))
.group_by(AnsibleRun.source_name, AnsibleRun.playbook_path).subquery())
rows = (await db.execute(select(AnsibleRun).join(newest, (
(AnsibleRun.source_name == newest.c.source_name)
& (AnsibleRun.playbook_path == newest.c.playbook_path)
& (AnsibleRun.started_at == newest.c.at))))).scalars().all()
wanted = set(keys)
return {(r.source_name, r.playbook_path): r for r in rows
if (r.source_name, r.playbook_path) in wanted}
@ansible_bp.get("/playbooks")
@require_role(UserRole.viewer)
async def playbooks():
"""The Playbooks tab (#5331): every playbook across sources in one list,
each with what it says it does and how it last ran; a source with none
is one quiet line under it."""
rows, empty = [], []
sources = _get_sources()
for source in sources:
paths = src_module.discover_playbooks(source["path"])
if not paths:
empty.append({**source, "editable": src_module.is_editable_source(source)})
for path in paths:
contents = src_module.read_playbook(source["path"], path)
meta = (src_module.discover_playbook_meta(contents) if contents is not None
else {"description": "", "category": "", "confirm": False})
rows.append({"source": source["name"], "path": path, **meta})
async with current_app.db_sessionmaker() as db:
last = await last_runs(db, [(r["source"], r["path"]) for r in rows])
for r in rows:
r["last"] = last.get((r["source"], r["path"]))
return await render_template(
"ansible/playbooks.html", rows=rows, empty_sources=empty, sources=sources,
can_add=bool(_editable_sources()) and session.get("user_role") == "admin")
@ansible_bp.get("/playbooks/<source_name>/<path:playbook_path>")
@require_role(UserRole.viewer)
async def playbook_page(source_name: str, playbook_path: str):
"""One playbook's home (#5331): what it does, the form that runs it, the
schedules that use it and its recent runs, then the playbook itself.
?from=<run id> fills the form from that run ("Run again")."""
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
source = _source(source_name)
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
meta = src_module.discover_playbook_meta(contents)
variables = src_module.discover_playbook_variables(contents)
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
runs = (await db.execute(select(AnsibleRun).where(
AnsibleRun.source_name == source_name, AnsibleRun.playbook_path == playbook_path)
.order_by(AnsibleRun.started_at.desc()).limit(RECENT_RUNS))).scalars().all()
scheds = (await db.execute(select(AnsibleSchedule).where(
AnsibleSchedule.source_name == source_name, AnsibleSchedule.playbook_path == playbook_path)
.order_by(AnsibleSchedule.name))).scalars().all()
labels = await scope_labels(db, [r.inventory_scope for r in runs] + [s.inventory_scope for s in scheds])
origins = await run_origins(db, runs)
targets = (await db.execute(select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all()
groups = (await db.execute(select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
again = None
if request.args.get("from"):
again = (await db.execute(select(AnsibleRun).where(
AnsibleRun.id == request.args["from"], AnsibleRun.source_name == source_name,
AnsibleRun.playbook_path == playbook_path))).scalar_one_or_none()
return await render_template(
"ansible/playbook.html", source=source, source_name=source_name, playbook_path=playbook_path,
contents=contents, description=meta["description"], category=meta["category"],
confirm=meta["confirm"], variables=variables, editable=src_module.is_editable_source(source),
runs=[{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs],
schedules=[{"s": s, "scope_label": labels.get(s.inventory_scope, s.inventory_scope),
"next": next_scheduled([s], now)} for s in scheds],
targets=targets, groups=groups, inventories=src_module.discover_inventories(source["path"]),
prefill=run_prefill(again, {v["name"] for v in variables}))
RECENT_RUNS = 10
def run_prefill(run, declared: set[str]) -> dict:
"""The run form's starting values: blank, or those of an earlier run
("Run again"). Declared variables fill their own fields; any other extra
var goes back into the free-form box. Secret values were never stored."""
if run is None:
return {"scope": "", "values": {}, "extra": "", "limit": "", "tags": "", "check": False}
params = run.params or {}
extra = params.get("extra_vars_map") or {}
return {"scope": run.inventory_scope,
"values": {k: v for k, v in extra.items() if k in declared},
"extra": "\n".join(f"{k}={v}" for k, v in extra.items() if k not in declared),
"limit": params.get("limit") or "", "tags": params.get("tags") or "",
"check": bool(params.get("check"))}
def _caller_role() -> UserRole:
return UserRole(session.get("user_role", "viewer"))
def _run_vars(params: dict | None, secret_vars: dict | None = None) -> dict:
return {**((params or {}).get("extra_vars_map") or {}), **(secret_vars or {})}
def check_run_form(form, source: dict | None, playbook_path: str,
params: dict | None, secret_vars: dict | None) -> str | None:
"""The server-side gates a web run must pass (the form's own checks are
only a convenience): who may set these variables, and the playbook's
steward:confirm flag, which a real (non-check) run must have ticked."""
err = guards.check_vars_for_role(_run_vars(params, secret_vars), _caller_role())
if err:
return err
content = src_module.read_playbook(source["path"], playbook_path) if source else None
meta = src_module.discover_playbook_meta(content or "")
if meta["confirm"] and not (params or {}).get("check") and "confirmed" not in form:
return (f"{playbook_path} is flagged destructive by its author (steward:confirm). "
"Tick the confirmation to run it, or run it in check mode.")
return None
def _parse_run_params(form) -> tuple[dict | None, dict | None, str | None]:
"""Build executor params + the unpersisted secret-vars dict from form fields.
Returns (params, secret_vars, error). Discovered variable fields are named
``var__<name>``; a sibling hidden ``secret__<name>`` marks the sensitive
ones, which go into secret_vars (never persisted) instead of params. A
free-form ``extra_vars`` textarea (key=value per line) is also folded in.
All run-time values flow through a JSON extra-vars file downstream, so
values may contain spaces/quotes safely.
"""
extra_vars_map: dict[str, str] = {}
secret_vars: dict[str, str] = {}
for key in set(form.keys()):
if not key.startswith("var__"):
continue
name = key[len("var__"):]
val = (form.get(key) or "").strip()
if not val:
continue # untouched → fall through to inventory/play default
if f"secret__{name}" in form:
secret_vars[name] = val
else:
extra_vars_map[name] = val
for line in (form.get("extra_vars", "") or "").splitlines():
line = line.strip()
if not line:
continue
if "=" not in line:
return None, None, f"Invalid extra var (expected key=value): {line!r}"
k, v = line.split("=", 1)
extra_vars_map[k.strip()] = v
params: dict = {}
if extra_vars_map:
params["extra_vars_map"] = extra_vars_map
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), (secret_vars or None), None
@ansible_bp.get("/playbook-options")
@require_role(UserRole.operator)
async def playbook_options():
"""HTMX fragment: <option>s of playbooks in a source, to populate a playbook
dropdown when a source is chosen. `selected` pre-selects one (edit forms)."""
source_name = (request.args.get("source_name", "") or "").strip()
selected = (request.args.get("selected", "") or "").strip()
source = next((s for s in _get_sources() if s["name"] == source_name), None)
playbooks = src_module.discover_playbooks(source["path"]) if source else []
return await render_template(
"ansible/_playbook_options.html", playbooks=playbooks, selected=selected)
@ansible_bp.get("/playbook-vars")
@require_role(UserRole.operator)
async def playbook_vars():
"""HTMX fragment: fill-in fields for a playbook's declared variables
(vars/vars_prompt), loaded when a playbook is chosen from a dropdown."""
source_name = (request.args.get("source_name", "") or "").strip()
playbook_path = (request.args.get("playbook_path", "") or "").strip()
# Schedule form: schedules can't prompt, so secret vars render disabled.
schedule = (request.args.get("schedule", "") or "").strip() == "1"
source = next((s for s in _get_sources() if s["name"] == source_name), None)
variables: list = []
meta: dict = {"description": "", "category": "", "confirm": False}
if source and playbook_path:
contents = src_module.read_playbook(source["path"], playbook_path)
if contents is not None:
variables = src_module.discover_playbook_variables(contents)
meta = src_module.discover_playbook_meta(contents)
return await render_template(
"ansible/_playbook_vars.html", variables=variables,
description=meta["description"], category=meta["category"],
confirm=meta["confirm"], schedule=schedule)
@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, secret_vars, err = _parse_run_params(form)
if err:
return err, 400
source = next((s for s in _get_sources() if s["name"] == source_name), None)
err = check_run_form(form, source, playbook_path, params_or_none, secret_vars)
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,
secret_vars=secret_vars,
triggered_by=session["user_id"],
)
if err == "Source not found":
return err, 404
if err:
return err, 400
await _audit("ansible.run.started", "ansible_run", run.id, {
"source": source_name, "playbook": playbook_path, "scope": inventory_scope,
"check": bool((params_or_none or {}).get("check")),
"vars": sorted(_run_vars(params_or_none, secret_vars))})
async with current_app.db_sessionmaker() as db:
labels = await scope_labels(db, [run.inventory_scope])
return await render_template(
"ansible/run_started.html",
run=run,
title=run_title(run.playbook_path, labels.get(run.inventory_scope, run.inventory_scope)),
)
@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 not in (AnsibleRunStatus.running, AnsibleRunStatus.queued):
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
origin = (await run_origins(db, [run]))[run.id]
labels = await scope_labels(db, [run.inventory_scope])
row = run_view(run, labels, datetime.now(timezone.utc))
hosts = [(name, host_recap(c, row["check"])) for name, c in hosts_worst_first(run.results)]
return await render_template("ansible/run_detail.html", run=run, origin=origin, hosts=hosts, row=row)
def hosts_worst_first(results: dict | None) -> list[tuple[str, dict]]:
"""A run's per-host recap, failed or unreachable hosts first, then those
that changed something, then the rest, each by name."""
hosts = (results or {}).get("hosts") or {}
return sorted(hosts.items(), key=lambda kv: (
-((kv[1].get("failed") or 0) + (kv[1].get("unreachable") or 0) > 0),
-((kv[1].get("changed") or 0) > 0), kv[0]))
@ansible_bp.post("/runs/<run_id>/cancel")
@require_role(UserRole.operator)
async def cancel_run(run_id: str):
async with current_app.db_sessionmaker() as db:
run = (await db.execute(
select(AnsibleRun).where(AnsibleRun.id == run_id))).scalar_one_or_none()
if run is None:
return "Not found", 404
if run.status in (AnsibleRunStatus.running, AnsibleRunStatus.queued):
executor.cancel_run(run_id)
await _audit("ansible.run.cancelled", "ansible_run", run_id,
{"playbook": run.playbook_path})
return redirect(url_for("ansible.run_detail", run_id=run_id))
@ansible_bp.get("/runs/<run_id>/log")
@require_role(UserRole.viewer)
async def run_log(run_id: str):
"""Download the full run log (persistent artifact; falls back to DB output)."""
import os
from steward.ansible.executor import artifact_path
async with current_app.db_sessionmaker() as db:
run = (await db.execute(
select(AnsibleRun).where(AnsibleRun.id == run_id))).scalar_one_or_none()
if run is None:
return "Not found", 404
path = artifact_path(run_id)
if os.path.exists(path):
with open(path, encoding="utf-8", errors="replace") as f:
body = f.read()
else:
body = run.output or ""
return Response(
body, mimetype="text/plain",
headers={"Content-Disposition": f'attachment; filename="ansible-run-{run_id[:8]}.log"'},
)
# ── Scheduled recurring runs ──────────────────────────────────────────────────
@ansible_bp.get("/schedules")
@require_role(UserRole.viewer)
async def schedules():
"""The Schedules tab (#5332): a verdict line, then each schedule as one
sentence (what runs where, how often, when next, how it last went),
failing first; the row opens its own page."""
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
scheds = (await db.execute(
select(AnsibleSchedule).order_by(AnsibleSchedule.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 {}
labels = await scope_labels(db, [s.inventory_scope for s in scheds])
rows = [{"s": s, "last_run": runs.get(s.last_run_id) if s.last_run_id else None,
"scope_label": labels.get(s.inventory_scope, s.inventory_scope),
"next": next_scheduled([s], now)} for s in scheds]
rows.sort(key=_schedule_order)
return await render_template(
"ansible/schedules.html", rows=rows, next=next_scheduled(scheds, now),
failing=sum(1 for r in rows if _schedule_order(r)[0] == 0),
paused=sum(1 for s in scheds if not s.enabled))
@ansible_bp.get("/schedules/new")
@ansible_bp.get("/schedules/<schedule_id>")
@require_role(UserRole.viewer)
async def schedule_page(schedule_id: str | None = None):
"""A schedule's own page (#5332): its form (operators), and at the foot
Delete behind the confirm dialog. /schedules/new is the empty form;
?source=&playbook= start it on a playbook ("Schedule it" on its page)."""
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
async with current_app.db_sessionmaker() as db:
editing = await db.get(AnsibleSchedule, schedule_id) if schedule_id else None
if schedule_id and editing is None:
return "Schedule not found", 404
groups = (await db.execute(
select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all()
targets = (await db.execute(
select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all()
last_run = await db.get(AnsibleRun, editing.last_run_id) if editing and editing.last_run_id else None
labels = await scope_labels(db, [editing.inventory_scope] if editing else [])
source_data = [
{"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])}
for s in _get_sources()
]
want_source = editing.source_name if editing else request.args.get("source", "")
want_playbook = editing.playbook_path if editing else request.args.get("playbook", "")
if not any(sd["name"] == want_source for sd in source_data):
want_source = source_data[0]["name"] if source_data else ""
sel_playbooks = next((sd["playbooks"] for sd in source_data if sd["name"] == want_source), [])
if want_playbook not in sel_playbooks:
want_playbook = sel_playbooks[0] if sel_playbooks else ""
# The chosen playbook's declared variables, rendered with the page so a
# saved schedule's values show (choosing another playbook reloads them
# through /playbook-vars).
variables: list = []
meta: dict = {"description": "", "category": "", "confirm": False}
src = _source(want_source)
if src and want_playbook:
contents = src_module.read_playbook(src["path"], want_playbook)
if contents is not None:
variables = src_module.discover_playbook_variables(contents)
meta = src_module.discover_playbook_meta(contents)
return await render_template(
"ansible/schedule.html", editing=editing, groups=groups, targets=targets,
source_data=source_data, sel_source=want_source, sel_playbook=want_playbook,
sel_playbooks=sel_playbooks, interval_presets=INTERVAL_PRESETS,
edit_variables=variables, edit_description=meta["description"],
edit_category=meta["category"], edit_confirm=meta["confirm"], last_run=last_run,
scope_label=labels.get(editing.inventory_scope) if editing else None,
next=next_scheduled([editing], datetime.now(timezone.utc)) if editing else None)
def _schedule_order(row: dict) -> tuple:
"""Schedules list order: failing first (its last run failed or was cut
short, or its last firing could not start one), then running, then the
rest, paused last; by name within each."""
s, run = row["s"], row["last_run"]
status = run.status.value if run else None
if not s.enabled:
rank = 3
elif status in ("failed", "interrupted") or (s.last_error and run is None):
rank = 0
elif status in ("queued", "running"):
rank = 1
else:
rank = 2
return rank, s.name.lower()
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"
# Scheduled runs can't prompt, so secret vars are intentionally dropped —
# automation should rely on global creds / inventory vars, not run-time secrets.
params, _secret_vars, 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
def _schedule_detail(fields: dict) -> dict:
"""What a schedule audit row records: what runs, where, how often — and
which variables, by name only."""
return {"name": fields["name"], "source": fields["source_name"],
"playbook": fields["playbook_path"], "scope": fields["inventory_scope"],
"interval_seconds": fields["interval_seconds"],
"vars": sorted(_run_vars(fields["params"]))}
@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
err = guards.check_vars_for_role(_run_vars(fields["params"]), _caller_role())
if err:
return err, 400
sched = AnsibleSchedule(enabled=True, **fields)
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(sched)
await _audit("ansible.schedule.created", "ansible_schedule", sched.id, _schedule_detail(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
err = guards.check_vars_for_role(
guards.changed_vars(_run_vars(fields["params"]), _run_vars(sched.params)),
_caller_role())
if err:
return err, 400
for k, v in fields.items():
setattr(sched, k, v)
await _audit("ansible.schedule.updated", "ansible_schedule", schedule_id, _schedule_detail(fields))
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
enabled, name = sched.enabled, sched.name
await _audit("ansible.schedule.enabled" if enabled else "ansible.schedule.disabled",
"ansible_schedule", schedule_id, {"name": name})
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 None:
return redirect(url_for("ansible.schedules"))
name = sched.name
await db.delete(sched)
await _audit("ansible.schedule.deleted", "ansible_schedule", schedule_id, {"name": name})
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"], origin="schedule", origin_id=schedule_id,
)
# Run now runs the playbook and leaves the schedule where it was: its
# time of day (last_run_at, the slot its next turn counts from) is not
# touched (operator, 2026-10-09). The run is still the schedule's latest,
# so the list shows it and a firing while it goes is skipped (#5158).
async with current_app.db_sessionmaker() as db:
async with db.begin():
fresh = await db.get(AnsibleSchedule, schedule_id)
if fresh:
if run:
fresh.last_run_id = run.id
fresh.last_error = err
await _audit("ansible.schedule.run_now", "ansible_schedule", schedule_id,
{"playbook": playbook_path, "run_id": run.id if run else None, "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
await _audit("ansible.playbook.updated" if editing else "ansible.playbook.created",
"ansible_playbook", f"{source_name}:{playbook_path}",
{"source": source_name, "playbook": playbook_path, "bytes": len(content)})
return redirect(url_for(
"ansible.playbook_page", 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
playbook_path = (form.get("playbook_path", "") or "").strip()
ok, err = src_module.delete_playbook(source["path"], playbook_path)
if not ok:
return err, 400
await _audit("ansible.playbook.deleted", "ansible_playbook", f"{source['name']}:{playbook_path}",
{"source": source["name"], "playbook": playbook_path})
return redirect(url_for("ansible.playbooks"))
_NEW_PLAYBOOK_TEMPLATE = """\
---
- name: My playbook
hosts: all
gather_facts: false
tasks:
- name: Ping
ansible.builtin.ping:
"""