#5212 "Plain labels: no raw ids, scopes, metric keys or unexplained jargon", batch 2: - A run records where it came from: AnsibleRun.origin ("schedule", "alert" or "mcp") and origin_id, set by the scheduler, Run now, alert actions and the MCP tools. Migration 0031 backfills past runs from alert events, schedules' last runs, and MCP audit entries. - The runs list has a Started by column: "schedule nightly", "alert rule nas down", "alice via MCP" or "alice", linked where there is a page. The raw scope tooltip is gone. - Run detail: no run id in the subtitle or body, Started by in words, and the host summary lists failed and unreachable hosts first. The live output only follows new lines while the reader is at the bottom, and when the run finishes the page reloads once so the badge and summary are final. - list_ansible_runs and get_ansible_run report origin and origin_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
718 lines
34 KiB
Python
718 lines
34 KiB
Python
"""Automation tools: playbooks, inventory, runs, schedules and alert actions.
|
|
|
|
Reads are viewer tools. Anything that starts a run or changes what runs is an
|
|
operator tool (set_alert_action is admin, as on the alert rule form), and each
|
|
of those writes an audit row as "<user> via MCP".
|
|
|
|
Playbook runs default to Ansible check mode (`--check --diff`): the agent sees
|
|
what would change before anything does, and applies only by passing
|
|
check=false. Variables the playbook marks secret reach the run but are never
|
|
stored or echoed back.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from collections import deque
|
|
from datetime import timedelta
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from steward.ansible import executor, guards, sources as src_module
|
|
from steward.ansible.inventory_gen import scope_labels
|
|
from steward.ansible.runner import trigger_run
|
|
from steward.mcp._context import current_caller, current_caller_or_none, mcp_app, public_url
|
|
from steward.models.alerts import AlertRule
|
|
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
|
|
from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget
|
|
from steward.models.ansible_schedule import AnsibleSchedule
|
|
from steward.models.hosts import Host
|
|
from steward.models.users import User, UserRole
|
|
|
|
from ._audit import audit_mcp
|
|
from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback, pick
|
|
|
|
ACTIVE = (AnsibleRunStatus.running, AnsibleRunStatus.queued)
|
|
RECAP_KEYS = ("ok", "changed", "failed", "unreachable", "skipped")
|
|
CONTENT_CHARS = 20000
|
|
LINE_CHARS = 500
|
|
WAIT_MAX_S = 120
|
|
POLL_S = 2.0
|
|
MASK = "<secret>"
|
|
|
|
|
|
# ── sources and playbooks ────────────────────────────────────────────────────
|
|
|
|
def _sources() -> list[dict]:
|
|
return src_module.get_sources(mcp_app().config.get("ANSIBLE", {}))
|
|
|
|
|
|
def _source(name: str) -> dict:
|
|
srcs = _sources()
|
|
for s in srcs:
|
|
if s["name"] == (name or "").strip():
|
|
return s
|
|
raise ValueError(f"no playbook source named {name!r}. Sources: "
|
|
f"{', '.join(s['name'] for s in srcs)} (list_playbooks shows their playbooks).")
|
|
|
|
|
|
def _playbook(source: dict, path: str) -> str:
|
|
"""The playbook's content; refuses a path the source does not have."""
|
|
path = (path or "").strip()
|
|
if path not in src_module.discover_playbooks(source["path"]):
|
|
raise ValueError(f"source {source['name']!r} has no playbook {path!r}; "
|
|
f'list_playbooks(source="{source["name"]}") lists them.')
|
|
content = src_module.read_playbook(source["path"], path)
|
|
if content is None:
|
|
raise ValueError(f"playbook {path!r} in {source['name']!r} could not be read.")
|
|
return content
|
|
|
|
|
|
def public_variables(variables: list[dict]) -> list[dict]:
|
|
"""A playbook's run-time variables with the defaults of secret ones masked."""
|
|
return [{**v, "default": MASK if v["secret"] and v["default"] not in ("", None) else v["default"]}
|
|
for v in variables]
|
|
|
|
|
|
# Variables Steward's own bundled playbooks take that Steward can answer itself.
|
|
STEWARD_KNOWN_VARS = {"steward_url": public_url}
|
|
|
|
|
|
def steward_defaults(source: dict, variables: list[dict], extra_vars: dict | None) -> tuple[dict, dict]:
|
|
"""(extra_vars, filled): a built-in playbook's Steward-known variables
|
|
(steward_url) filled in when the caller left them out or empty, as the
|
|
web actions do. Other sources' playbooks are left as they are."""
|
|
extra_vars = dict(extra_vars or {})
|
|
if source.get("name") != src_module.BUILTIN_SOURCE_NAME:
|
|
return extra_vars, {}
|
|
filled = {}
|
|
declared = {v["name"] for v in variables}
|
|
for name, value in STEWARD_KNOWN_VARS.items():
|
|
if name in declared and not str(extra_vars.get(name) or "").strip():
|
|
known = value()
|
|
if known:
|
|
extra_vars[name] = filled[name] = known
|
|
return extra_vars, filled
|
|
|
|
|
|
def split_vars(variables: list[dict], extra_vars: dict | None) -> tuple[dict, dict]:
|
|
"""(plain, secret): the values the run stores, and those it must not.
|
|
|
|
Refuses a vars_prompt variable that has no default and no value, since a
|
|
run cannot stop to ask for it.
|
|
"""
|
|
extra_vars = dict(extra_vars or {})
|
|
for k, v in extra_vars.items():
|
|
if not isinstance(k, str) or not k.strip():
|
|
raise ValueError("extra_vars keys must be non-empty variable names.")
|
|
if not isinstance(v, (str, int, float, bool)):
|
|
raise ValueError(f"extra_vars[{k!r}] must be a string, number or boolean.")
|
|
secret_names = {v["name"] for v in variables if v["secret"]}
|
|
missing = [v["name"] for v in variables if v["required"] and v["name"] not in extra_vars]
|
|
if missing:
|
|
raise ValueError(f"this playbook prompts for {', '.join(missing)} and has no default; "
|
|
"pass a value in extra_vars. Nothing was run.")
|
|
plain = {k: v for k, v in extra_vars.items() if k not in secret_names}
|
|
secret = {k: v for k, v in extra_vars.items() if k in secret_names}
|
|
return plain, secret
|
|
|
|
|
|
def check_caller_vars(extra_vars: dict | None, previous: dict | None = None) -> None:
|
|
"""Refuse variables this caller's role may not set (ansible/guards.py):
|
|
connection-type ansible_* names and Jinja in values are admin-only. On an
|
|
update only what changes is held to it."""
|
|
caller = current_caller_or_none()
|
|
err = guards.check_vars_for_role(guards.changed_vars(extra_vars, previous),
|
|
caller.role if caller else None)
|
|
if err:
|
|
raise ValueError(f"{err} Nothing was run or saved.")
|
|
|
|
|
|
def build_params(plain: dict, limit: str, tags: str, check: bool) -> dict:
|
|
params: dict = {}
|
|
if plain:
|
|
params["extra_vars_map"] = plain
|
|
if (limit or "").strip():
|
|
params["limit"] = limit.strip()
|
|
if (tags or "").strip():
|
|
params["tags"] = tags.strip()
|
|
if check:
|
|
params["check"] = True
|
|
return params
|
|
|
|
|
|
async def _scope(db, source: dict, scope: str) -> tuple[str, str]:
|
|
"""(stored scope, human description) from the forms the docstrings offer."""
|
|
s = (scope or "all").strip()
|
|
if s in ("all", "steward:all"):
|
|
n = len((await db.execute(select(AnsibleTarget.id))).all())
|
|
if not n:
|
|
raise ValueError("Steward's inventory has no targets yet; add one under Ansible → "
|
|
"Inventory, or pass scope=\"inventory:<file>\" for a repo inventory.")
|
|
return "steward:all", f"all {n} inventory targets"
|
|
kind, _, want = s.removeprefix("steward:").partition(":")
|
|
if kind == "target":
|
|
rows = (await db.execute(select(AnsibleTarget))).scalars()
|
|
t = pick(want, rows, kind="target", list_tool="list_inventory")
|
|
return f"steward:target:{t.id}", f"target {t.name} ({t.address})"
|
|
if kind == "group":
|
|
rows = (await db.execute(select(AnsibleGroup).options(selectinload(AnsibleGroup.targets)))).scalars()
|
|
g = pick(want, rows, kind="group", list_tool="list_inventory")
|
|
if not g.targets:
|
|
raise ValueError(f"group {g.name!r} has no targets. Nothing was run.")
|
|
return f"steward:group:{g.id}", f"group {g.name} ({len(g.targets)} targets)"
|
|
if kind in ("inventory", "repo"):
|
|
inv = want.rsplit(":", 1)[-1] if kind == "repo" else want
|
|
found = src_module.discover_inventories(source["path"])
|
|
if inv not in found:
|
|
raise ValueError(f"source {source['name']!r} has no inventory file {inv!r}"
|
|
+ (f"; it has {', '.join(found)}." if found else "; it has none."))
|
|
return f"repo:{source['name']}:{inv}", f"{source['name']}/{inv}"
|
|
raise ValueError(f"scope {scope!r} is not one of: all, target:<name or id>, "
|
|
"group:<name or id>, inventory:<file>. list_inventory shows the choices.")
|
|
|
|
|
|
async def list_playbooks(source: str = "", category: str = "", limit: int = 200) -> dict:
|
|
"""The playbooks Steward can run, by source, with what each one says it does.
|
|
|
|
source: one source's name; empty for all. "steward-builtin" holds
|
|
Steward's own maintenance playbooks, "steward-local" those written in
|
|
Steward's editor; the rest come from configured directories or git repos.
|
|
category: only playbooks whose `# steward:category:` matches; empty for all.
|
|
limit: default 200, max 1000.
|
|
|
|
`confirm: true` marks a playbook its author flagged as destructive:
|
|
run_playbook will apply it only with confirmed=true. get_playbook shows a
|
|
playbook's variables and content.
|
|
"""
|
|
limit = clamp_limit(limit, 200, 1000)
|
|
srcs = [_source(source)] if source else _sources()
|
|
rows, out_sources = [], []
|
|
for s in srcs:
|
|
inventories = src_module.discover_inventories(s["path"])
|
|
out_sources.append({"source": s["name"], "type": s["type"],
|
|
"editable": src_module.is_editable_source(s), "inventories": inventories})
|
|
for path in src_module.discover_playbooks(s["path"]):
|
|
meta = src_module.discover_playbook_meta(src_module.read_playbook(s["path"], path) or "")
|
|
if category and meta["category"].lower() != category.strip().lower():
|
|
continue
|
|
rows.append({"source": s["name"], "playbook": path, **meta})
|
|
return {"sources": out_sources,
|
|
**bounded(rows, limit, key="playbooks", more="Filter by `source` or `category`.")}
|
|
|
|
|
|
async def get_playbook(source: str, playbook: str) -> dict:
|
|
"""One playbook: its description, the variables it takes, and its content.
|
|
|
|
`variables` are what run_playbook's extra_vars can set: `required` ones
|
|
(a vars_prompt with no default) must be given; `secret` ones are passed
|
|
to the run but never stored, and their defaults are shown as "<secret>".
|
|
`content` is the YAML itself, cut at 20000 characters.
|
|
"""
|
|
s = _source(source)
|
|
content = _playbook(s, playbook)
|
|
variables = src_module.discover_playbook_variables(content)
|
|
_, filled = steward_defaults(s, variables, {})
|
|
variables = [{**v, "default": filled[v["name"]], "filled_by_steward": True}
|
|
if v["name"] in filled else v for v in variables]
|
|
out = {
|
|
"source": s["name"],
|
|
"playbook": playbook.strip(),
|
|
**src_module.discover_playbook_meta(content),
|
|
"variables": public_variables(variables),
|
|
"content": content[:CONTENT_CHARS],
|
|
}
|
|
if len(content) > CONTENT_CHARS:
|
|
out["content_truncated"] = f"{len(content) - CONTENT_CHARS} more characters not shown."
|
|
return out
|
|
|
|
|
|
async def list_inventory() -> dict:
|
|
"""Steward's Ansible inventory: targets, groups, and how to scope a run to them.
|
|
|
|
Each target names the Steward host it is linked to (prune_docker needs
|
|
that link), its groups and the names of its Ansible variables — not their
|
|
values, which can hold credentials. `scopes` lists what run_playbook's
|
|
`scope` accepts.
|
|
"""
|
|
async with db_session() as db:
|
|
targets = list((await db.execute(
|
|
select(AnsibleTarget).options(selectinload(AnsibleTarget.groups))
|
|
.order_by(AnsibleTarget.name))).scalars())
|
|
groups = list((await db.execute(
|
|
select(AnsibleGroup).options(selectinload(AnsibleGroup.targets))
|
|
.order_by(AnsibleGroup.name))).scalars())
|
|
host_names = dict((await db.execute(select(Host.id, Host.name))).all())
|
|
return {
|
|
"targets": [
|
|
{"target_id": t.id, "name": t.name, "address": t.address, "host_id": t.host_id,
|
|
"host_name": host_names.get(t.host_id), "groups": sorted(g.name for g in t.groups),
|
|
"var_names": sorted(t.ansible_vars or {})}
|
|
for t in targets
|
|
],
|
|
"groups": [
|
|
{"group_id": g.id, "name": g.name, "targets": sorted(t.name for t in g.targets),
|
|
"var_names": sorted(g.ansible_vars or {})}
|
|
for g in groups
|
|
],
|
|
"repo_inventories": [{"source": s["name"], "files": inv} for s in _sources()
|
|
if (inv := src_module.discover_inventories(s["path"]))],
|
|
"scopes": {
|
|
"all": "every target above",
|
|
"target:<name or id>": "one target",
|
|
"group:<name or id>": "every target in a group",
|
|
"inventory:<file>": "an inventory file in the playbook's own source",
|
|
},
|
|
}
|
|
|
|
|
|
# ── runs ─────────────────────────────────────────────────────────────────────
|
|
|
|
def recap_totals(results: dict | None) -> dict | None:
|
|
hosts = (results or {}).get("hosts") or {}
|
|
if not hosts:
|
|
return None
|
|
return {"hosts": len(hosts),
|
|
**{k: sum(int((h or {}).get(k) or 0) for h in hosts.values()) for k in RECAP_KEYS}}
|
|
|
|
|
|
def run_row(r: AnsibleRun, users: dict, labels: dict) -> dict:
|
|
"""`labels` is scope_labels() for the runs' scopes."""
|
|
end = r.finished_at
|
|
return {
|
|
"run_id": r.id,
|
|
"source": r.source_name,
|
|
"playbook": r.playbook_path,
|
|
"scope": r.inventory_scope,
|
|
"scope_label": labels.get(r.inventory_scope, r.inventory_scope),
|
|
"status": r.status.value,
|
|
"check": bool((r.params or {}).get("check")),
|
|
"triggered_by": users.get(r.triggered_by, "(deleted user)") if r.triggered_by else "system",
|
|
# "schedule" / "alert" (origin_id = that schedule or rule), "mcp", or null for the web pages.
|
|
"origin": r.origin,
|
|
"origin_id": r.origin_id,
|
|
"started_at": iso(r.started_at),
|
|
"age_s": age_s(r.started_at),
|
|
"finished_at": iso(end),
|
|
"duration_s": int((end - r.started_at).total_seconds()) if end and r.started_at else None,
|
|
"recap": recap_totals(r.results),
|
|
}
|
|
|
|
|
|
async def _users(db, ids) -> dict[str, str]:
|
|
ids = {i for i in ids if i}
|
|
if not ids:
|
|
return {}
|
|
return dict((await db.execute(select(User.id, User.username).where(User.id.in_(ids)))).all())
|
|
|
|
|
|
async def list_ansible_runs(status: str = "", playbook: str = "", range: str = "7d",
|
|
limit: int = 50) -> dict:
|
|
"""Ansible runs, newest first: manual, scheduled, alert-triggered and MCP-started.
|
|
|
|
status: queued, running, success, failed, interrupted or cancelled; empty for all.
|
|
playbook: a substring of the playbook path; empty for all.
|
|
range: "1h", "6h", "24h", "7d" (default) or "30d".
|
|
limit: default 50, max 500.
|
|
|
|
`check: true` is a dry run that changed nothing. `recap` sums the play
|
|
recap over hosts; get_ansible_run shows per-host results and the log.
|
|
"""
|
|
since, key = lookback(range)
|
|
limit = clamp_limit(limit, 50, 500)
|
|
stmt = (select(AnsibleRun).where(AnsibleRun.started_at >= since)
|
|
.order_by(AnsibleRun.started_at.desc()).limit(limit + 1))
|
|
if status:
|
|
try:
|
|
stmt = stmt.where(AnsibleRun.status == AnsibleRunStatus(status.strip()))
|
|
except ValueError:
|
|
raise ValueError(f"status {status!r} is not one of "
|
|
f"{', '.join(s.value for s in AnsibleRunStatus)}.") from None
|
|
if playbook:
|
|
esc = playbook.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
stmt = stmt.where(AnsibleRun.playbook_path.ilike(f"%{esc}%", escape="\\"))
|
|
async with db_session() as db:
|
|
runs = list((await db.execute(stmt)).scalars())
|
|
users = await _users(db, [r.triggered_by for r in runs])
|
|
labels = await scope_labels(db, [r.inventory_scope for r in runs])
|
|
out = {"range": key, **bounded([run_row(r, users, labels) for r in runs], limit, key="runs")}
|
|
if out["truncated"]:
|
|
out["total"] = None
|
|
out["more"] = (f"More than {limit} runs in range. Pass a larger `limit` (max 500), "
|
|
"a shorter `range`, or filter by `status` or `playbook`.")
|
|
return out
|
|
|
|
|
|
def _cut(line: str) -> str:
|
|
return line if len(line) <= LINE_CHARS else line[:LINE_CHARS] + f" [… {len(line) - LINE_CHARS} more]"
|
|
|
|
|
|
def read_log(run: AnsibleRun, tail: int, grep: str, max_matches: int = 100) -> dict:
|
|
"""The last `tail` lines of a run's log and the lines containing `grep`."""
|
|
path = executor.artifact_path(run.id)
|
|
if os.path.exists(path):
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
lines = f.read().splitlines()
|
|
else:
|
|
lines = (run.output or "").splitlines()
|
|
out: dict = {"log_lines": len(lines), "tail": [_cut(x) for x in deque(lines, maxlen=tail)]}
|
|
if grep:
|
|
needle = grep.lower()
|
|
hits = [{"line_no": i + 1, "line": _cut(x)} for i, x in enumerate(lines) if needle in x.lower()]
|
|
out["matches"] = hits[:max_matches]
|
|
out["matches_total"] = len(hits)
|
|
return out
|
|
|
|
|
|
async def _load_run(db, run_id: str) -> AnsibleRun:
|
|
run = await db.get(AnsibleRun, (run_id or "").strip())
|
|
if run is None:
|
|
raise ValueError(f"no Ansible run with id {run_id!r}; list_ansible_runs lists them.")
|
|
return run
|
|
|
|
|
|
async def _run_detail(run_id: str, tail: int, grep: str) -> dict:
|
|
async with db_session() as db:
|
|
run = await _load_run(db, run_id)
|
|
users = await _users(db, [run.triggered_by])
|
|
labels = await scope_labels(db, [run.inventory_scope])
|
|
params = dict(run.params or {})
|
|
return {
|
|
**run_row(run, users, labels),
|
|
"limit": params.get("limit"),
|
|
"tags": params.get("tags"),
|
|
"extra_vars": params.get("extra_vars_map") or {},
|
|
"hosts": (run.results or {}).get("hosts") or {},
|
|
"failures": ((run.results or {}).get("failures") or [])[:20],
|
|
**read_log(run, tail, grep),
|
|
}
|
|
|
|
|
|
async def get_ansible_run(run_id: str, tail: int = 200, grep: str = "") -> dict:
|
|
"""One Ansible run: its parameters, per-host recap, failures and log.
|
|
|
|
tail: how many of the log's last lines to return; default 200, max 2000.
|
|
grep: also return every log line containing this text (case-insensitive),
|
|
with its line number, up to 100.
|
|
|
|
`failures` are the task failures Steward parsed out of the log; start
|
|
there when status is failed. Secret variables were never stored, so they
|
|
do not appear in `extra_vars`.
|
|
"""
|
|
return await _run_detail(run_id, clamp_limit(tail, 200, 2000), (grep or "").strip())
|
|
|
|
|
|
async def wait_for_run(run_id: str, timeout_s: int = 60) -> dict:
|
|
"""Wait for a run to finish, then return it as get_ansible_run does (last 50 lines).
|
|
|
|
timeout_s: how long to wait; default 60, max 120. If the run is still
|
|
going when it runs out, `finished` is false: call again to keep waiting.
|
|
"""
|
|
deadline = asyncio.get_running_loop().time() + max(1, min(int(timeout_s or 60), WAIT_MAX_S))
|
|
while True:
|
|
async with db_session() as db:
|
|
status = (await _load_run(db, run_id)).status
|
|
if status not in ACTIVE or asyncio.get_running_loop().time() >= deadline:
|
|
break
|
|
await asyncio.sleep(POLL_S)
|
|
out = await _run_detail(run_id, 50, "")
|
|
out["finished"] = status not in ACTIVE
|
|
return out
|
|
|
|
|
|
async def run_playbook(source: str, playbook: str, scope: str = "all",
|
|
extra_vars: dict | None = None, limit: str = "", tags: str = "",
|
|
check: bool = True, confirmed: bool = False) -> dict:
|
|
"""Start an Ansible playbook run. A dry run (check mode) unless check=false.
|
|
|
|
source, playbook: as list_playbooks shows them.
|
|
scope: which hosts — "all" (default), "target:<name or id>",
|
|
"group:<name or id>", or "inventory:<file>" for an inventory file in the
|
|
same source. list_inventory shows the choices.
|
|
extra_vars: {"name": value}. Variables the playbook marks secret are
|
|
passed to the run but never stored or returned. Steward's built-in
|
|
playbooks get `steward_url` (Steward's own address) filled in when it is
|
|
left out; the reply's `filled_by_steward` says so.
|
|
limit, tags: Ansible's --limit and --tags; empty for none.
|
|
check: true (default) runs with --check --diff, which reports what would
|
|
change and changes nothing. Pass false to apply — after a check run
|
|
looked right.
|
|
confirmed: a playbook with `confirm: true` (destructive, by its author's
|
|
flag) applies only when this is also true.
|
|
|
|
Returns at once with `run_id`; wait_for_run follows it to the end. Recorded
|
|
in the audit log as "<you> via MCP".
|
|
"""
|
|
s = _source(source)
|
|
content = _playbook(s, playbook)
|
|
meta = src_module.discover_playbook_meta(content)
|
|
if not check and meta["confirm"] and not confirmed:
|
|
raise ValueError(f"{playbook} is flagged destructive by its author (steward:confirm). "
|
|
"Run it with check=true first; to apply, pass check=false and "
|
|
"confirmed=true. Nothing was run.")
|
|
check_caller_vars(extra_vars)
|
|
variables = src_module.discover_playbook_variables(content)
|
|
extra_vars, filled = steward_defaults(s, variables, extra_vars)
|
|
plain, secret = split_vars(variables, extra_vars)
|
|
async with db_session() as db:
|
|
stored_scope, scope_desc = await _scope(db, s, scope)
|
|
params = build_params(plain, limit, tags, check)
|
|
caller = current_caller()
|
|
run, _src, err = await trigger_run(
|
|
mcp_app(), source_name=s["name"], playbook_path=playbook.strip(),
|
|
inventory_scope=stored_scope, params=params or None,
|
|
secret_vars=secret or None, triggered_by=caller.user_id, origin="mcp")
|
|
if err:
|
|
raise ValueError(f"the run could not start: {err}.")
|
|
await audit_mcp("ansible.run.started", "ansible_run", run.id, {
|
|
"source": s["name"], "playbook": playbook.strip(), "scope": stored_scope,
|
|
"scope_label": scope_desc, "check": check, "limit": params.get("limit"), "tags": params.get("tags"),
|
|
"extra_vars": sorted(plain), "secret_vars": sorted(secret)})
|
|
return {
|
|
"run_id": run.id,
|
|
"check": check,
|
|
"scope": scope_desc,
|
|
"status": run.status.value,
|
|
"secret_vars_passed": sorted(secret),
|
|
**({"filled_by_steward": filled} if filled else {}),
|
|
"summary": (f"{'Check-mode (dry) run' if check else 'Run'} of {playbook.strip()} "
|
|
f"on {scope_desc} started."
|
|
+ (" It changes nothing; pass check=false to apply." if check else "")),
|
|
"next": f'wait_for_run(run_id="{run.id}")',
|
|
}
|
|
|
|
|
|
async def cancel_ansible_run(run_id: str) -> dict:
|
|
"""Stop a queued or running Ansible run. Recorded in the audit log."""
|
|
async with db_session() as db:
|
|
run = await _load_run(db, run_id)
|
|
if run.status not in ACTIVE:
|
|
raise ValueError(f"run {run.id} already ended ({run.status.value}); nothing to cancel.")
|
|
executor.cancel_run(run.id)
|
|
await audit_mcp("ansible.run.cancelled", "ansible_run", run.id, {"playbook": run.playbook_path})
|
|
return {"run_id": run.id, "cancelling": True,
|
|
"next": f'wait_for_run(run_id="{run.id}", timeout_s=30)'}
|
|
|
|
|
|
# ── schedules ────────────────────────────────────────────────────────────────
|
|
|
|
def schedule_row(sc: AnsibleSchedule, last: AnsibleRun | None, labels: dict) -> dict:
|
|
params = sc.params or {}
|
|
nxt = sc.last_run_at + timedelta(seconds=sc.interval_seconds) if sc.last_run_at else None
|
|
return {
|
|
"schedule_id": sc.id,
|
|
"name": sc.name,
|
|
"source": sc.source_name,
|
|
"playbook": sc.playbook_path,
|
|
"scope": sc.inventory_scope,
|
|
"scope_label": labels.get(sc.inventory_scope, sc.inventory_scope),
|
|
"interval_s": sc.interval_seconds,
|
|
"enabled": sc.enabled,
|
|
"check": bool(params.get("check")),
|
|
"extra_vars": params.get("extra_vars_map") or {},
|
|
"limit": params.get("limit"),
|
|
"tags": params.get("tags"),
|
|
"last_run_at": iso(sc.last_run_at),
|
|
"last_run_id": sc.last_run_id,
|
|
"last_run_status": last.status.value if last else None,
|
|
"last_error": sc.last_error,
|
|
"next_run_at": iso(nxt) if sc.enabled else None,
|
|
}
|
|
|
|
|
|
async def _schedules(db) -> list[AnsibleSchedule]:
|
|
return list((await db.execute(select(AnsibleSchedule).order_by(AnsibleSchedule.name))).scalars())
|
|
|
|
|
|
async def list_schedules() -> dict:
|
|
"""Recurring playbook runs: what runs, how often, and how its last run went.
|
|
|
|
`check: true` schedules are dry runs that never change anything.
|
|
`last_error` is set when a firing could not start at all (a source or
|
|
playbook that went away), or was skipped because the schedule's previous
|
|
run was still going (then `last_run_id` still names that run). A run that
|
|
started and failed shows in `last_run_status`, and
|
|
get_ansible_run(last_run_id) has its log.
|
|
"""
|
|
async with db_session() as db:
|
|
scheds = await _schedules(db)
|
|
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_(ids)))).scalars()} if ids else {}
|
|
labels = await scope_labels(db, [s.inventory_scope for s in scheds])
|
|
rows = [schedule_row(s, runs.get(s.last_run_id), labels) for s in scheds]
|
|
return {"schedules": rows, "total": len(rows)}
|
|
|
|
|
|
async def _schedule_fields(db, source: str, playbook: str, scope: str, extra_vars: dict | None,
|
|
limit: str, tags: str, check: bool) -> dict:
|
|
s = _source(source)
|
|
content = _playbook(s, playbook)
|
|
variables = src_module.discover_playbook_variables(content)
|
|
plain, secret = split_vars(variables, steward_defaults(s, variables, extra_vars)[0])
|
|
if secret:
|
|
raise ValueError(f"{', '.join(sorted(secret))} {'is' if len(secret) == 1 else 'are'} secret "
|
|
"and a schedule would have to store it. Put it in the target's or group's "
|
|
"Ansible variables instead. Nothing was saved.")
|
|
stored_scope, _desc = await _scope(db, s, scope)
|
|
return {"source_name": s["name"], "playbook_path": playbook.strip(), "inventory_scope": stored_scope,
|
|
"params": build_params(plain, limit, tags, check) or None}
|
|
|
|
|
|
def _interval(interval_s: int) -> int:
|
|
if not isinstance(interval_s, int) or interval_s <= 0:
|
|
raise ValueError("interval_s must be a positive number of seconds, e.g. 86400 for daily.")
|
|
return interval_s
|
|
|
|
|
|
async def create_schedule(name: str, source: str, playbook: str, interval_s: int,
|
|
scope: str = "all", extra_vars: dict | None = None, limit: str = "",
|
|
tags: str = "", check: bool = True, enabled: bool = True) -> dict:
|
|
"""Run a playbook on a recurring interval. Each firing is a "system" run.
|
|
|
|
interval_s: seconds between runs, e.g. 3600 hourly, 86400 daily.
|
|
scope, extra_vars, limit, tags: as run_playbook. Secret variables cannot
|
|
be scheduled (they would have to be stored); set them as inventory vars.
|
|
check: true (default) schedules dry runs. Pass false only for a playbook
|
|
already proven by a check run.
|
|
|
|
Recorded in the audit log as "<you> via MCP".
|
|
"""
|
|
if not (name or "").strip():
|
|
raise ValueError("a schedule needs a name.")
|
|
check_caller_vars(extra_vars)
|
|
async with db_session() as db, db.begin():
|
|
fields = await _schedule_fields(db, source, playbook, scope, extra_vars, limit, tags, check)
|
|
sc = AnsibleSchedule(name=name.strip()[:128], interval_seconds=_interval(interval_s),
|
|
enabled=enabled, **fields)
|
|
db.add(sc)
|
|
labels = await scope_labels(db, [sc.inventory_scope])
|
|
await audit_mcp("ansible.schedule.created", "ansible_schedule", sc.id,
|
|
{"name": sc.name, "playbook": sc.playbook_path, "interval_s": sc.interval_seconds,
|
|
"check": check})
|
|
return schedule_row(sc, None, labels)
|
|
|
|
|
|
async def update_schedule(schedule: str, name: str | None = None, source: str | None = None,
|
|
playbook: str | None = None, interval_s: int | None = None,
|
|
scope: str | None = None, extra_vars: dict | None = None,
|
|
limit: str | None = None, tags: str | None = None,
|
|
check: bool | None = None, enabled: bool | None = None) -> dict:
|
|
"""Change a schedule. Only the arguments you pass change; the rest stay.
|
|
|
|
schedule: its name or id (list_schedules). Pass enabled=false to pause it.
|
|
extra_vars, when passed, replaces the whole set. Recorded in the audit log.
|
|
"""
|
|
async with db_session() as db, db.begin():
|
|
sc = pick(schedule, await _schedules(db), kind="schedule", list_tool="list_schedules")
|
|
params = dict(sc.params or {})
|
|
if extra_vars is not None:
|
|
check_caller_vars(extra_vars, params.get("extra_vars_map"))
|
|
cur_scope = sc.inventory_scope
|
|
if cur_scope.startswith("repo:"):
|
|
cur_scope = "inventory:" + cur_scope.rsplit(":", 1)[-1]
|
|
fields = await _schedule_fields(
|
|
db, source if source is not None else sc.source_name,
|
|
playbook if playbook is not None else sc.playbook_path,
|
|
scope if scope is not None else cur_scope,
|
|
extra_vars if extra_vars is not None else params.get("extra_vars_map"),
|
|
limit if limit is not None else params.get("limit", ""),
|
|
tags if tags is not None else params.get("tags", ""),
|
|
check if check is not None else bool(params.get("check")))
|
|
for k, v in fields.items():
|
|
setattr(sc, k, v)
|
|
if name is not None and name.strip():
|
|
sc.name = name.strip()[:128]
|
|
if interval_s is not None:
|
|
sc.interval_seconds = _interval(interval_s)
|
|
if enabled is not None:
|
|
sc.enabled = enabled
|
|
row = schedule_row(sc, None, await scope_labels(db, [sc.inventory_scope]))
|
|
changed = sorted(k for k, v in {"name": name, "source": source, "playbook": playbook,
|
|
"interval_s": interval_s, "scope": scope, "extra_vars": extra_vars,
|
|
"limit": limit, "tags": tags, "check": check,
|
|
"enabled": enabled}.items() if v is not None)
|
|
await audit_mcp("ansible.schedule.updated", "ansible_schedule", row["schedule_id"],
|
|
{"name": row["name"], "changed": changed})
|
|
return row
|
|
|
|
|
|
async def delete_schedule(schedule: str) -> dict:
|
|
"""Delete a schedule (its past runs stay). Recorded in the audit log.
|
|
|
|
schedule: its name or id. To pause instead, update_schedule(enabled=false).
|
|
"""
|
|
async with db_session() as db, db.begin():
|
|
sc = pick(schedule, await _schedules(db), kind="schedule", list_tool="list_schedules")
|
|
await db.delete(sc)
|
|
await audit_mcp("ansible.schedule.deleted", "ansible_schedule", sc.id,
|
|
{"name": sc.name, "playbook": sc.playbook_path})
|
|
return {"deleted": sc.name, "schedule_id": sc.id}
|
|
|
|
|
|
# ── alert actions ────────────────────────────────────────────────────────────
|
|
|
|
async def set_alert_action(rule: str, source: str = "", playbook: str = "", inventory: str = "",
|
|
extra_vars: dict | None = None, limit: str = "", tags: str = "",
|
|
check: bool = True) -> dict:
|
|
"""Set, replace or clear the playbook an alert rule runs when it starts firing.
|
|
|
|
rule: the alert rule's name or id (list_alert_rules).
|
|
playbook: empty clears the rule's action. Otherwise source, playbook and
|
|
inventory (an inventory file in that source, as list_playbooks shows)
|
|
are all required.
|
|
extra_vars: {"name": value}, stored on the rule. The run also receives the
|
|
alert's own context variables. Secret variables cannot be stored here.
|
|
check: true (default) makes the action a dry run each time it fires;
|
|
pass false for a remediation already proven by a check run.
|
|
|
|
Admin only, as on the alert rule form. Recorded in the audit log.
|
|
"""
|
|
async with db_session() as db, db.begin():
|
|
r = pick(rule, (await db.execute(select(AlertRule))).scalars(),
|
|
kind="alert rule", list_tool="list_alert_rules")
|
|
action = None
|
|
if (playbook or "").strip():
|
|
s = _source(source)
|
|
content = _playbook(s, playbook)
|
|
inv = (inventory or "").strip()
|
|
found = src_module.discover_inventories(s["path"])
|
|
if inv not in found:
|
|
raise ValueError(f"source {s['name']!r} has no inventory file {inv!r}"
|
|
+ (f"; it has {', '.join(found)}." if found else "; it has none.")
|
|
+ " Nothing was saved.")
|
|
variables = src_module.discover_playbook_variables(content)
|
|
plain, secret = split_vars(variables, steward_defaults(s, variables, extra_vars)[0])
|
|
if secret:
|
|
raise ValueError(f"{', '.join(sorted(secret))} is secret and an alert action would "
|
|
"have to store it. Nothing was saved.")
|
|
action = {"source": s["name"], "playbook": playbook.strip(), "inventory": inv}
|
|
if plain:
|
|
action["extra_vars"] = [f"{k}={v}" for k, v in plain.items()]
|
|
if (limit or "").strip():
|
|
action["limit"] = limit.strip()
|
|
if (tags or "").strip():
|
|
action["tags"] = tags.strip()
|
|
if check:
|
|
action["check"] = True
|
|
r.ansible_action = action
|
|
name, rid = r.name, r.id
|
|
await audit_mcp("alert_rule.action_set" if action else "alert_rule.action_cleared",
|
|
"alert_rule", name,
|
|
{"rule_id": rid, **({k: v for k, v in action.items() if k != "extra_vars"}
|
|
if action else {})})
|
|
return {"rule_id": rid, "rule_name": name, "action": action,
|
|
"summary": (f"When {name} starts firing it runs {action['playbook']}"
|
|
f"{' in check mode' if action.get('check') else ''}."
|
|
if action else f"{name} no longer runs a playbook when it fires.")}
|
|
|
|
|
|
def register(register_tool) -> None:
|
|
for fn in (list_playbooks, get_playbook, list_inventory, list_ansible_runs,
|
|
get_ansible_run, list_schedules, wait_for_run):
|
|
register_tool(fn, role=UserRole.viewer, read_only=True)
|
|
for fn in (run_playbook, cancel_ansible_run, create_schedule, update_schedule, delete_schedule):
|
|
register_tool(fn, role=UserRole.operator, read_only=False)
|
|
register_tool(set_alert_action, role=UserRole.admin, read_only=False)
|