- /plugins/syslog/sources/<id>: the lines newest first, loading as you scroll, filtered by severity and worse, app, text and time, with day dividers and times to the second in the reader's zone. - The Syslog page links each source and shows its lines in the last hour. - A Syslog panel on a host's page and an SNMP device's: the newest lines of the sources bound to it or at its address. - MCP: list_syslog_sources and search_syslog (source, host, text, severity and worse, app, range, paging by next_before). - Shared: escape_like moves to steward/core/like.py and cut_middle to the MCP shaping module, replacing four inline copies; |when takes seconds=. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
874 lines
42 KiB
Python
874 lines
42 KiB
Python
"""Automation tools: playbooks, inventory and its groups, 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.core.like import escape_like
|
|
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
|
|
|
|
|
|
def _editable(name: str) -> dict:
|
|
s = _source(name)
|
|
if not src_module.is_editable_source(s):
|
|
why = ("it holds Steward's own playbooks, which are changed in Steward's repository"
|
|
if s["name"] == src_module.BUILTIN_SOURCE_NAME else
|
|
"it is read from a git repository, which would overwrite a change on its next pull"
|
|
if s["type"] == "git" else "it is not editable")
|
|
raise ValueError(f"source {s['name']!r} can't be written here: {why}. Write this "
|
|
f"instance's own playbooks into {src_module.LOCAL_SOURCE_NAME!r}.")
|
|
return s
|
|
|
|
|
|
async def save_playbook(playbook: str, content: str, source: str = src_module.LOCAL_SOURCE_NAME,
|
|
replace: bool = False) -> dict:
|
|
"""Write a playbook of this instance's own: automation for these hosts that
|
|
isn't part of Steward itself. The same as saving in Steward's editor.
|
|
|
|
playbook: a path in the source ending .yml or .yaml, e.g. "upkeep/prune-runner.yml".
|
|
content: the whole playbook. It must parse as YAML and be a list of plays,
|
|
or nothing is saved and the error (with its line) comes back.
|
|
source: "steward-local" (default), or another editable folder source.
|
|
steward-builtin is refused: Steward's own playbooks are changed in
|
|
Steward's repository. Git sources are refused: their repo owns them.
|
|
replace: an existing playbook is overwritten only with replace=true, so
|
|
a typo in a name never clobbers another one.
|
|
|
|
After saving, `ansible-playbook --syntax-check` runs on it (no host is
|
|
contacted); `syntax` says whether it passed and, if not, why, e.g. a
|
|
module name Ansible can't resolve. The file is saved either way.
|
|
|
|
To test it: run_playbook(source, playbook, scope="target:<one host>") runs
|
|
it in check mode, get_ansible_run shows what would change; fix, save with
|
|
replace=true, run again; apply with check=false once it is right.
|
|
|
|
Admin only. Recorded in the audit log (its size, never its content).
|
|
"""
|
|
s = _editable(source)
|
|
path = (playbook or "").strip().lstrip("/")
|
|
target, err = src_module.resolve_writable(s["path"], path)
|
|
if err:
|
|
raise ValueError(f"{err}. Nothing was saved.")
|
|
ok, err = src_module.validate_playbook_yaml(content or "")
|
|
if not ok:
|
|
raise ValueError(f"{err}\nNothing was saved.")
|
|
existed = target.exists()
|
|
if existed and not replace:
|
|
raise ValueError(f"{s['name']} already has {path!r}; pass replace=true to overwrite it, "
|
|
"or choose another name. Nothing was saved.")
|
|
ok, err = src_module.write_playbook(s["path"], path, content)
|
|
if not ok:
|
|
raise ValueError(f"{err}. Nothing was saved.")
|
|
await audit_mcp("ansible.playbook.updated" if existed else "ansible.playbook.created",
|
|
"ansible_playbook", f"{s['name']}:{path}",
|
|
{"source": s["name"], "playbook": path, "bytes": len(content)})
|
|
passed, output = await executor.syntax_check(s["path"], path)
|
|
syntax = {"ok": passed}
|
|
if output:
|
|
syntax["output" if passed is False else "note"] = output
|
|
return {"source": s["name"], "playbook": path, "replaced": existed, "bytes": len(content),
|
|
"syntax": syntax,
|
|
"next": f'run_playbook(source="{s["name"]}", playbook="{path}", '
|
|
'scope="target:<one host>") to see what it would change.'}
|
|
|
|
|
|
async def delete_playbook(playbook: str, source: str = src_module.LOCAL_SOURCE_NAME) -> dict:
|
|
"""Delete a playbook written into steward-local (or another editable folder
|
|
source). Its past runs stay. A schedule or alert action that names it
|
|
will fail until it is pointed elsewhere.
|
|
|
|
Admin only. Recorded in the audit log.
|
|
"""
|
|
s = _editable(source)
|
|
path = (playbook or "").strip().lstrip("/")
|
|
_playbook(s, path)
|
|
ok, err = src_module.delete_playbook(s["path"], path)
|
|
if not ok:
|
|
raise ValueError(err)
|
|
await audit_mcp("ansible.playbook.deleted", "ansible_playbook", f"{s['name']}:{path}",
|
|
{"source": s["name"], "playbook": path})
|
|
return {"deleted": path, "source": s["name"]}
|
|
|
|
|
|
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",
|
|
},
|
|
}
|
|
|
|
|
|
def member_targets(names: list[str] | None, targets: list, host_names: dict[str, str]) -> list:
|
|
"""The targets `names` mean, each a target's name or id, or the name of the
|
|
Steward host a target is linked to. Refuses the whole list when one is
|
|
unknown, so nothing changes on a typo."""
|
|
by_host = {host_names[t.host_id].lower(): t for t in targets
|
|
if t.host_id and host_names.get(t.host_id)}
|
|
found, unknown = [], []
|
|
for want in names or []:
|
|
want = (want or "").strip()
|
|
if not want:
|
|
continue
|
|
t = next((t for t in targets if t.id == want or t.name.lower() == want.lower()), None) \
|
|
or by_host.get(want.lower())
|
|
(found if t else unknown).append(t or want)
|
|
if unknown:
|
|
raise ValueError(f"no target or linked host named {', '.join(map(repr, unknown))}; "
|
|
"list_inventory lists them. Nothing was changed.")
|
|
return list({t.id: t for t in found}.values())
|
|
|
|
|
|
async def update_group_members(group: str, add: list[str] | None = None,
|
|
remove: list[str] | None = None) -> dict:
|
|
"""Put targets into an inventory group, or take them out.
|
|
|
|
Inventory groups are Steward's grouping system: an alert rule covering a
|
|
group watches every host in it, the Hosts list filters by them, and a run
|
|
can be scoped to one. A host is in a group through its linked target.
|
|
|
|
group: the group's name or id (list_inventory).
|
|
add, remove: targets by name or id, or by the name of the Steward host a
|
|
target is linked to. One already in (or out of) the group is left as it
|
|
is; an unknown name refuses the whole change.
|
|
|
|
Returns the group's targets after the change and the alert rules that
|
|
cover it; their per-host rules follow within a minute. Recorded in the
|
|
audit log as "<you> via MCP".
|
|
"""
|
|
if not (add or remove):
|
|
raise ValueError("pass add and/or remove: the targets to put in or take out.")
|
|
async with db_session() as db, db.begin():
|
|
g = pick(group, (await db.execute(
|
|
select(AnsibleGroup).options(selectinload(AnsibleGroup.targets)))).scalars(),
|
|
kind="group", list_tool="list_inventory")
|
|
targets = list((await db.execute(select(AnsibleTarget))).scalars())
|
|
host_names = dict((await db.execute(select(Host.id, Host.name))).all())
|
|
to_add = member_targets(add, targets, host_names)
|
|
to_remove = member_targets(remove, targets, host_names)
|
|
both = {t.name for t in to_add} & {t.name for t in to_remove}
|
|
if both:
|
|
raise ValueError(f"{', '.join(sorted(both))} is in both add and remove. Nothing was changed.")
|
|
have = {t.id for t in g.targets}
|
|
added = [t for t in to_add if t.id not in have]
|
|
removed = [t for t in to_remove if t.id in have]
|
|
g.targets = [t for t in g.targets if t.id not in {r.id for r in removed}] + added
|
|
members = sorted(t.name for t in g.targets)
|
|
covering = sorted((await db.execute(select(AlertRule.name).where(
|
|
AlertRule.covers == "group", AlertRule.cover_group == g.name))).scalars())
|
|
name, gid = g.name, g.id
|
|
if added or removed:
|
|
# The Inventory page's own record of a group's membership changing.
|
|
await audit_mcp("ansible.group.updated", "ansible_group", gid,
|
|
{"name": name, "targets": members,
|
|
"added": sorted(t.name for t in added),
|
|
"removed": sorted(t.name for t in removed)})
|
|
return {"group": name, "group_id": gid,
|
|
"added": sorted(t.name for t in added), "removed": sorted(t.name for t in removed),
|
|
"targets": members, "alert_rules": covering,
|
|
**({"note": "Alert rules covering this group pick up the change within a minute."}
|
|
if covering and (added or removed) else {})}
|
|
|
|
|
|
# ── 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:
|
|
stmt = stmt.where(AnsibleRun.playbook_path.ilike(f"%{escape_like(playbook)}%", 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,
|
|
update_group_members):
|
|
register_tool(fn, role=UserRole.operator, read_only=False)
|
|
for fn in (set_alert_action, save_playbook, delete_playbook):
|
|
register_tool(fn, role=UserRole.admin, read_only=False)
|