feat(mcp): automation tools — playbooks, runs, schedules, alert actions, docker prune
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 57s
CI / unit (push) Failing after 1m8s
CI / publish (push) Skipped

MCP-6 #4970 "Automation tools: playbooks, runs, schedules, alert actions".

Viewer: list_playbooks, get_playbook (secret defaults masked), list_inventory
(variable names only), list_ansible_runs, get_ansible_run (log tail + grep),
list_schedules, wait_for_run (polls, 120s cap).

Operator: run_playbook (check mode unless check=false; a steward:confirm
playbook applies only with confirmed=true; secret variables reach the run
but are never stored, audited or returned), cancel_ansible_run, and
create/update/delete_schedule (secrets refused, check by default).

Admin: set_alert_action, and the docker plugin's prune_docker, which
previews from the disk report unless check=false (Ansible's check mode
skips the prune commands, so a check run would show nothing).

Every write is audited as "<user> via MCP" with the token id; the audit
tool's user filter includes those rows. prune_extra_vars moves to the
docker service so the page and the tool share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 08:06:15 -04:00
co-authored by Claude Opus 5.5
parent 21338dbcd9
commit 680a6bf3b6
13 changed files with 1124 additions and 32 deletions
+79 -1
View File
@@ -11,11 +11,15 @@ from datetime import datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
from steward.mcp._context import mcp_app from steward.ansible.runner import trigger_run
from steward.ansible.sources import BUILTIN_SOURCE_NAME
from steward.mcp._context import current_caller, mcp_app
from steward.mcp.tools._audit import audit_mcp
from steward.mcp.tools._shape import ( from steward.mcp.tools._shape import (
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd, summarize, age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd, summarize,
) )
from steward.mcp.tools.overview import finding from steward.mcp.tools.overview import finding
from steward.models.ansible_inventory import AnsibleTarget
from steward.models.hosts import Host from steward.models.hosts import Host
from steward.models.users import UserRole from steward.models.users import UserRole
@@ -412,7 +416,81 @@ async def get_docker_disk_usage(host: str = "") -> dict:
return {"hosts": rows, "total": len(rows)} return {"hosts": rows, "total": len(rows)}
PRUNE_BUILTIN_PLAYBOOK = "maintenance/docker_prune.yml"
_STOPPED = ("exited", "created", "dead")
def prune_preview(what: str, usage, stopped: int) -> dict:
"""What a prune of `what` would remove, from the host's latest disk report."""
out: dict = {}
if what in ("containers", "system"):
out["stopped_containers"] = stopped
if what == "images":
out["unused_images_bytes"] = usage.images_reclaimable if usage else None
if what == "system":
out["dangling_images"] = "yes (unused but tagged images stay)"
out["build_cache_bytes"] = usage.build_cache_size if usage else None
out["unused_networks"] = "yes"
return out
async def prune_docker(host: str, what: str, check: bool = True) -> dict:
"""Reclaim Docker disk on a host. Previews unless check=false.
host: the Steward host's name or id. It needs a linked Ansible target
(list_inventory shows the links); the prune runs through Ansible as
Steward's built-in docker_prune playbook.
what: "containers" removes stopped containers; "images" removes ALL
unused images, not only dangling ones; "system" is `docker system
prune -f`: stopped containers, dangling images, unused networks and
build cache. Volumes are never pruned.
check: true (default) starts nothing and returns `would_remove`, sized
from the host's latest Docker disk report. Ansible's own check mode
skips the prune commands, so a check-mode run would show nothing.
Pass false to run the prune.
Admin only, as on the Docker → Disk page. A real prune returns `run_id`
(follow it with wait_for_run) and is recorded in the audit log.
"""
what = (what or "").strip()
if what not in docker_svc.PRUNE_TARGETS:
raise ValueError(f"what {what!r} is not one of {', '.join(docker_svc.PRUNE_TARGETS)}.")
async with db_session() as db:
h = pick(host, (await db.execute(select(Host))).scalars(), kind="host", list_tool="list_hosts")
target = (await db.execute(
select(AnsibleTarget).where(AnsibleTarget.host_id == h.id))).scalars().first()
usage = await db.get(DockerDiskUsage, h.id)
stopped = sum(1 for c in await docker_svc.list_containers(db, h.id)
if (c.status or "").lower() in _STOPPED)
if target is None:
raise ValueError(f"host {h.name!r} has no linked Ansible target, so Steward cannot reach it "
"to prune. Link one under Ansible → Inventory. Nothing was run.")
preview = {
"host": h.name,
"what": what,
"ansible_target": target.name,
"would_remove": prune_preview(what, usage, stopped),
"disk_report_age_s": age_s(usage.updated_at) if usage else None,
}
if check:
return {**preview, "check": True,
"summary": f"Nothing was run. Pass check=false to prune {what} on {h.name}."}
caller = current_caller()
run, _src, err = await trigger_run(
mcp_app(), source_name=BUILTIN_SOURCE_NAME, playbook_path=PRUNE_BUILTIN_PLAYBOOK,
inventory_scope=f"steward:target:{target.id}",
params={"extra_vars_map": docker_svc.prune_extra_vars(what)},
triggered_by=caller.user_id)
if err:
raise ValueError(f"the prune could not start: {err}.")
await audit_mcp("docker.prune.started", "host", h.name,
{"host_id": h.id, "what": what, "run_id": run.id})
return {**preview, "check": False, "run_id": run.id,
"next": f'wait_for_run(run_id="{run.id}")'}
def register_tools(register) -> None: def register_tools(register) -> None:
for fn in (list_containers, get_container, list_swarm_services, for fn in (list_containers, get_container, list_swarm_services,
search_container_logs, get_docker_disk_usage): search_container_logs, get_docker_disk_usage):
register(fn, role=UserRole.viewer, read_only=True) register(fn, role=UserRole.viewer, read_only=True)
register(prune_docker, role=UserRole.admin, read_only=False)
+2 -13
View File
@@ -31,17 +31,6 @@ def _error(status: int, code: str, detail: str | None = None):
return jsonify(body), status return jsonify(body), status
def _prune_extra_vars(prune_target: str) -> dict:
"""Extra-vars for the bundled docker_prune playbook. "Prune unused images"
means ALL unused (docker image prune -a), not just dangling; the full system
prune stays conservative (-f, no -a) per the operator's choice (m78). Pure
helper so the mapping is unit-tested without the request/DB stack."""
extra_vars: dict = {"prune_target": prune_target}
if prune_target == "images":
extra_vars["prune_all_images"] = True
return extra_vars
def _human_bytes(n: int | None) -> str: def _human_bytes(n: int | None) -> str:
"""Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B').""" """Compact binary size string (e.g. '1.4 GiB', '512 MiB', '0 B')."""
if n is None: if n is None:
@@ -544,7 +533,7 @@ async def disk_prune(host_id: str):
form = await request.form form = await request.form
prune_target = (form.get("target", "") or "").strip() prune_target = (form.get("target", "") or "").strip()
if prune_target not in ("containers", "images", "system"): if prune_target not in docker_svc.PRUNE_TARGETS:
return _error(400, "bad_target", "Unknown prune target") return _error(400, "bad_target", "Unknown prune target")
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
@@ -556,7 +545,7 @@ async def disk_prune(host_id: str):
"Link an Ansible target to this host before pruning") "Link an Ansible target to this host before pruning")
# extra_vars_map outranks the playbook's own `vars:` defaults. # extra_vars_map outranks the playbook's own `vars:` defaults.
extra_vars = _prune_extra_vars(prune_target) extra_vars = docker_svc.prune_extra_vars(prune_target)
actor_role = UserRole(session.get("user_role", "viewer")) actor_role = UserRole(session.get("user_role", "viewer"))
run, _source, err = await invoke_capability( run, _source, err = await invoke_capability(
+13
View File
@@ -183,3 +183,16 @@ async def recent_failures_by_container(db, since) -> dict[tuple[str, str], list]
for e in rows: for e in rows:
out.setdefault((e.host_id, e.container_name), []).append(e) out.setdefault((e.host_id, e.container_name), []).append(e)
return out return out
PRUNE_TARGETS = ("containers", "images", "system")
def prune_extra_vars(prune_target: str) -> dict:
"""Extra-vars for the bundled docker_prune playbook. "Prune unused images"
means ALL unused (docker image prune -a), not just dangling; the full system
prune stays conservative (-f, no -a) per the operator's choice (m78)."""
extra_vars: dict = {"prune_target": prune_target}
if prune_target == "images":
extra_vars["prune_all_images"] = True
return extra_vars
+2 -2
View File
@@ -12,9 +12,9 @@ each argument's empty value means, and what comes back.
""" """
from __future__ import annotations from __future__ import annotations
from . import alerts, audit, hosts, metrics, monitors, overview, session from . import alerts, audit, automation, hosts, metrics, monitors, overview, session
_MODULES = (session, overview, hosts, monitors, alerts, metrics, audit) _MODULES = (session, overview, hosts, monitors, alerts, metrics, audit, automation)
def register_all(register_tool) -> None: def register_all(register_tool) -> None:
+24
View File
@@ -0,0 +1,24 @@
"""Audit trail for changes made through MCP.
Every mutating tool records its change with `audit_mcp`, under the calling
user's id and the username "<user> via MCP" (an operator ruling on the API
Surface), so the audit log separates what a person did from what an agent
did on their token. The token id goes into the detail.
"""
from __future__ import annotations
from steward.core.audit import log_audit
from steward.mcp._context import current_caller, mcp_app
VIA = " via MCP"
def mcp_username(username: str) -> str:
return (username + VIA)[:64]
async def audit_mcp(action: str, entity_type: str | None = None, entity_id: str | None = None,
detail: dict | None = None) -> None:
caller = current_caller()
await log_audit(mcp_app(), caller.user_id, mcp_username(caller.username), action,
entity_type, entity_id, {**(detail or {}), "token_id": caller.token_id})
+6 -3
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
import json import json
from sqlalchemy import select from sqlalchemy import or_, select
from steward.models.audit import AuditEvent from steward.models.audit import AuditEvent
from steward.models.users import UserRole from steward.models.users import UserRole
from ._audit import mcp_username
from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback
@@ -17,7 +18,8 @@ async def list_audit_events(action: str = "", user: str = "", entity_type: str =
action: a prefix of the action name, e.g. "monitor." or "ansible.run"; action: a prefix of the action name, e.g. "monitor." or "ansible.run";
empty for all. empty for all.
user: a username, exactly; "system" for automated changes. user: a username, exactly; "system" for automated changes. Includes the
changes that user made through MCP, which are recorded as "<user> via MCP".
entity_type: e.g. "monitor", "host", "alert_rule"; empty for all. entity_type: e.g. "monitor", "host", "alert_rule"; empty for all.
range: "1h", "6h", "24h", "7d" (default) or "30d". range: "1h", "6h", "24h", "7d" (default) or "30d".
limit: default 50, max 500. limit: default 50, max 500.
@@ -33,7 +35,8 @@ async def list_audit_events(action: str = "", user: str = "", entity_type: str =
esc = action.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") esc = action.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
stmt = stmt.where(AuditEvent.action.like(esc + "%", escape="\\")) stmt = stmt.where(AuditEvent.action.like(esc + "%", escape="\\"))
if user: if user:
stmt = stmt.where(AuditEvent.username == user) stmt = stmt.where(or_(AuditEvent.username == user,
AuditEvent.username == mcp_username(user)))
if entity_type: if entity_type:
stmt = stmt.where(AuditEvent.entity_type == entity_type) stmt = stmt.where(AuditEvent.entity_type == entity_type)
async with db_session() as db: async with db_session() as db:
+657
View File
@@ -0,0 +1,657 @@
"""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, sources as src_module
from steward.ansible.runner import trigger_run
from steward.mcp._context import current_caller, mcp_app
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]
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 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)
out = {
"source": s["name"],
"playbook": playbook.strip(),
**src_module.discover_playbook_meta(content),
"variables": public_variables(src_module.discover_playbook_variables(content)),
"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) -> dict:
end = r.finished_at
return {
"run_id": r.id,
"source": r.source_name,
"playbook": r.playbook_path,
"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",
"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])
out = {"range": key, **bounded([run_row(r, users) 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])
params = dict(run.params or {})
return {
**run_row(run, users),
"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.
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.")
plain, secret = split_vars(src_module.discover_playbook_variables(content), 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)
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,
"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),
"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) -> 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,
"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); 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 {}
rows = [schedule_row(s, runs.get(s.last_run_id)) 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)
plain, secret = split_vars(src_module.discover_playbook_variables(content), extra_vars)
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.")
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)
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)
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 {})
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)
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.")
plain, secret = split_vars(src_module.discover_playbook_variables(content), extra_vars)
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)
+4 -2
View File
@@ -6,8 +6,10 @@ from steward.models.users import UserRole
_ROLE_MEANS = { _ROLE_MEANS = {
UserRole.viewer: "Read-only: every list_*/get_* tool. Nothing that changes Steward or your hosts.", UserRole.viewer: "Read-only: every list_*/get_* tool. Nothing that changes Steward or your hosts.",
UserRole.operator: "Viewer tools, plus running playbooks and changing monitors, alerts and schedules.", UserRole.operator: "Viewer tools, plus running playbooks (check mode unless told otherwise) "
UserRole.admin: "Everything, including deleting monitors and alert rules.", "and changing monitors, alerts and schedules.",
UserRole.admin: "Everything, including the playbook an alert rule runs, pruning Docker disk, "
"and deleting monitors and alert rules.",
} }
+196
View File
@@ -0,0 +1,196 @@
"""Integration: the automation tools against real Postgres and a real ansible-playbook.
One local playbook (connection: local) writes a file. A run started through
run_playbook is check mode unless told otherwise, so the first run must leave
no file behind and the confirmed apply must write it. Every write is audited as
"<user> via MCP", and the secret variable reaches the play without being
stored, audited or echoed.
"""
from __future__ import annotations
import asyncio
import json
import os
import textwrap
import uuid
import pytest
pytestmark = pytest.mark.integration
_NEEDS_DB = pytest.mark.skipif(
not os.environ.get("STEWARD_DATABASE_URL"),
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
)
SECRET = "s3cr3t-value-xyz"
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
@_NEEDS_DB
def test_runs_schedules_and_alert_actions_through_mcp(app, tmp_path):
from sqlalchemy import select
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.mcp.tools import automation as a
from steward.models.alerts import AlertOperator, AlertRule
from steward.models.ansible import AnsibleRun
from steward.models.ansible_inventory import AnsibleTarget
from steward.models.audit import AuditEvent
from steward.models.users import User, UserRole
tag = uuid.uuid4().hex[:8]
src, uid, username = f"mcp-src-{tag}", str(uuid.uuid4()), f"mcp-op-{tag}"
target_name, rule_name = f"mcp-target-{tag}", f"mcp-rule-{tag}"
out_file = tmp_path / "applied.txt"
play_dir = tmp_path / "playbooks"
play_dir.mkdir()
(play_dir / "play.yml").write_text(textwrap.dedent("""\
# description: Writes a marker file (MCP integration test)
# steward:confirm: true
- hosts: all
connection: local
gather_facts: false
vars:
greeting: hello
api_token: changeme
out_file: /nonexistent
tasks:
- debug:
msg: "GREETING-{{ greeting }} TOKEN-LEN-{{ api_token | length }}"
- copy:
dest: "{{ out_file }}"
content: applied
"""))
(play_dir / "hosts").write_text("localhost ansible_connection=local\n")
app.config["ANSIBLE"] = {"sources": [{"name": src, "type": "local", "path": str(play_dir)}]}
async def _seed():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=username, email=f"{tag}@e.test",
password_hash="x", role=UserRole.admin, is_active=True))
async with s.begin():
s.add(AnsibleTarget(name=target_name, address="127.0.0.1",
ansible_vars={"ansible_connection": "local"}))
s.add(AlertRule(name=rule_name, source_module="host_agent", resource_name="nas",
metric_name="cpu_pct", operator=AlertOperator.gt, threshold=90.0,
created_by=uid))
async def _audit(action, entity_id=None):
async with app.db_sessionmaker() as s:
stmt = select(AuditEvent).where(AuditEvent.action == action,
AuditEvent.user_id == uid)
if entity_id:
stmt = stmt.where(AuditEvent.entity_id == entity_id)
return list((await s.execute(stmt)).scalars())
async def _go():
await _seed()
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id=uid, username=username,
role=UserRole.admin, token_id=42))
# ── discovery ──────────────────────────────────────────────────────
books = (await a.list_playbooks(source=src))["playbooks"]
assert [(b["playbook"], b["confirm"]) for b in books] == [("play.yml", True)]
pb = await a.get_playbook(src, "play.yml")
defaults = {v["name"]: (v["default"], v["secret"]) for v in pb["variables"]}
assert defaults["api_token"] == ("<secret>", True)
assert defaults["greeting"] == ("hello", False)
inv = await a.list_inventory()
assert any(t["name"] == target_name and t["var_names"] == ["ansible_connection"]
for t in inv["targets"])
with pytest.raises(ValueError, match="has no playbook 'nope.yml'"):
await a.get_playbook(src, "nope.yml")
# ── a check-mode run is the default and changes nothing ───────────
extra = {"greeting": "yo", "api_token": SECRET, "out_file": str(out_file)}
started = await a.run_playbook(src, "play.yml", scope=f"target:{target_name}",
extra_vars=extra)
assert started["check"] is True and started["secret_vars_passed"] == ["api_token"]
assert SECRET not in json.dumps(started)
done = await a.wait_for_run(started["run_id"], timeout_s=120)
assert done["finished"] and done["status"] == "success", done["tail"]
assert done["check"] is True and done["triggered_by"] == username
assert not out_file.exists()
log = "\n".join(done["tail"])
assert "GREETING-yo" in log and f"TOKEN-LEN-{len(SECRET)}" in log
assert SECRET not in json.dumps(done)
async with app.db_sessionmaker() as s:
run = await s.get(AnsibleRun, started["run_id"])
assert run.params["check"] is True and SECRET not in json.dumps(run.params)
rows = await _audit("ansible.run.started", started["run_id"])
assert len(rows) == 1 and rows[0].username == f"{username} via MCP"
detail = json.loads(rows[0].detail_json)
assert detail["check"] is True and detail["token_id"] == 42
assert detail["secret_vars"] == ["api_token"] and SECRET not in rows[0].detail_json
# ── applying a confirm playbook needs confirmed=true ──────────────
with pytest.raises(ValueError, match="flagged destructive"):
await a.run_playbook(src, "play.yml", scope=f"target:{target_name}",
extra_vars=extra, check=False)
applied = await a.run_playbook(src, "play.yml", scope=f"target:{target_name}",
extra_vars=extra, check=False, confirmed=True)
done = await a.wait_for_run(applied["run_id"], timeout_s=120)
assert done["status"] == "success" and done["check"] is False, done["tail"]
assert out_file.read_text() == "applied"
listed = (await a.list_ansible_runs(playbook="play.yml", range="1h", limit=500))["runs"]
ours = {r["run_id"]: r for r in listed}
assert ours[started["run_id"]]["check"] is True
assert ours[applied["run_id"]]["check"] is False
grep = await a.get_ansible_run(applied["run_id"], grep="greeting-")
assert grep["matches_total"] >= 1
with pytest.raises(ValueError, match="already ended"):
await a.cancel_ansible_run(applied["run_id"])
with pytest.raises(ValueError, match="scope 'everything'"):
await a.run_playbook(src, "play.yml", scope="everything")
with pytest.raises(ValueError, match="no target named"):
await a.run_playbook(src, "play.yml", scope=f"target:ghost-{tag}")
# ── schedules ──────────────────────────────────────────────────────
with pytest.raises(ValueError, match="api_token is secret.*Nothing was saved"):
await a.create_schedule(f"sched-{tag}", src, "play.yml", 3600,
scope=f"target:{target_name}", extra_vars=extra)
sc = await a.create_schedule(f"sched-{tag}", src, "play.yml", 3600,
scope=f"target:{target_name}",
extra_vars={"out_file": str(out_file)})
assert sc["check"] is True and sc["enabled"] is True
up = await a.update_schedule(f"sched-{tag}", enabled=False, interval_s=86400)
assert (up["enabled"], up["interval_s"], up["check"]) == (False, 86400, True)
assert up["extra_vars"] == {"out_file": str(out_file)} # untouched fields stay
assert any(s["schedule_id"] == sc["schedule_id"] for s in (await a.list_schedules())["schedules"])
await a.delete_schedule(sc["schedule_id"])
assert not any(s["schedule_id"] == sc["schedule_id"]
for s in (await a.list_schedules())["schedules"])
for action in ("ansible.schedule.created", "ansible.schedule.updated",
"ansible.schedule.deleted"):
assert len(await _audit(action, sc["schedule_id"])) == 1, action
# ── alert actions ──────────────────────────────────────────────────
with pytest.raises(ValueError, match="no inventory file 'missing'"):
await a.set_alert_action(rule_name, src, "play.yml", "missing")
act = await a.set_alert_action(rule_name, src, "play.yml", "hosts",
extra_vars={"greeting": "alert"})
assert act["action"] == {"source": src, "playbook": "play.yml", "inventory": "hosts",
"extra_vars": ["greeting=alert"], "check": True}
cleared = await a.set_alert_action(rule_name)
assert cleared["action"] is None
async with app.db_sessionmaker() as s:
rule = (await s.execute(select(AlertRule).where(AlertRule.name == rule_name))).scalar_one()
assert rule.ansible_action is None
assert len(await _audit("alert_rule.action_set", rule_name)) == 1
assert len(await _audit("alert_rule.action_cleared", rule_name)) == 1
asyncio.run(_go())
+109
View File
@@ -0,0 +1,109 @@
"""The automation tools' pure parts and their gates: check mode by default,
secrets kept out of what is stored, roles, and refusals that run nothing.
A real check-mode run, applied run and audit rows are covered against
Postgres in tests/integration/test_mcp_automation.py.
"""
from __future__ import annotations
import inspect
from types import SimpleNamespace as NS
import pytest
from steward.core.api_tokens import Caller
from steward.mcp.tools import automation
from steward.models.users import UserRole
VARS = [
{"name": "api_token", "default": "abc", "secret": True, "required": False, "prompt": None},
{"name": "greeting", "default": "hi", "secret": False, "required": False, "prompt": None},
{"name": "release", "default": "", "secret": False, "required": True, "prompt": "Release?"},
]
VIEWER_TOOLS = {"list_playbooks", "get_playbook", "list_inventory", "list_ansible_runs",
"get_ansible_run", "list_schedules", "wait_for_run"}
OPERATOR_TOOLS = {"run_playbook", "cancel_ansible_run", "create_schedule", "update_schedule",
"delete_schedule"}
def test_split_vars_keeps_secrets_apart_from_what_is_stored():
plain, secret = automation.split_vars(VARS, {"api_token": "s3cr3t", "greeting": "yo",
"release": "1.2", "other": 5})
assert plain == {"greeting": "yo", "release": "1.2", "other": 5}
assert secret == {"api_token": "s3cr3t"}
def test_split_vars_refuses_a_missing_prompt_and_odd_values():
with pytest.raises(ValueError, match="prompts for release.*Nothing was run"):
automation.split_vars(VARS, {"greeting": "yo"})
with pytest.raises(ValueError, match="string, number or boolean"):
automation.split_vars(VARS[:2], {"greeting": ["a"]})
def test_public_variables_mask_secret_defaults():
out = {v["name"]: v["default"] for v in automation.public_variables(VARS)}
assert out == {"api_token": "<secret>", "greeting": "hi", "release": ""}
def test_build_params():
assert automation.build_params({}, "", "", True) == {"check": True}
assert automation.build_params({"a": 1}, " web01 ", "deploy", False) == {
"extra_vars_map": {"a": 1}, "limit": "web01", "tags": "deploy"}
def test_writes_default_to_check_mode():
for fn in (automation.run_playbook, automation.create_schedule, automation.set_alert_action):
assert inspect.signature(fn).parameters["check"].default is True, fn.__name__
async def test_run_playbook_will_not_apply_a_confirm_playbook_unconfirmed(monkeypatch):
monkeypatch.setattr(automation, "_source", lambda name: {"name": name, "path": "/x"})
monkeypatch.setattr(automation, "_playbook",
lambda s, p: "# steward:confirm: true\n- hosts: all\n tasks: []\n")
def _no_run(*a, **k):
raise AssertionError("a run was started")
monkeypatch.setattr(automation, "trigger_run", _no_run)
with pytest.raises(ValueError, match="flagged destructive.*confirmed=true"):
await automation.run_playbook("s", "wipe.yml", check=False)
def test_recap_totals_sum_over_hosts():
results = {"hosts": {"a": {"ok": 3, "changed": 1}, "b": {"ok": 2, "failed": 1}}}
assert automation.recap_totals(results) == {
"hosts": 2, "ok": 5, "changed": 1, "failed": 1, "unreachable": 0, "skipped": 0}
assert automation.recap_totals(None) is None
def test_read_log_tail_and_grep(monkeypatch, tmp_path):
monkeypatch.setattr(automation.executor, "artifact_path", lambda rid: str(tmp_path / "none.log"))
run = NS(id="r1", output="\n".join(["PLAY [all]", "TASK [x]", "fatal: boom", "x" * 900, "PLAY RECAP"]))
out = automation.read_log(run, tail=2, grep="FATAL")
assert out["log_lines"] == 5
assert out["tail"][1] == "PLAY RECAP" and out["tail"][0].endswith("[… 400 more]")
assert out["matches"] == [{"line_no": 3, "line": "fatal: boom"}] and out["matches_total"] == 1
(tmp_path / "r1.log").write_text("from the artifact\n")
monkeypatch.setattr(automation.executor, "artifact_path", lambda rid: str(tmp_path / f"{rid}.log"))
assert automation.read_log(run, tail=10, grep="")["tail"] == ["from the artifact"]
def test_automation_tools_are_registered_with_their_roles(app):
mcp = app.mcp_server
for name in VIEWER_TOOLS:
assert mcp.tool_roles[name] is UserRole.viewer, name
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is True, name
for name in OPERATOR_TOOLS:
assert mcp.tool_roles[name] is UserRole.operator, name
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is False, name
assert mcp.tool_roles["set_alert_action"] is UserRole.admin
for name in VIEWER_TOOLS | OPERATOR_TOOLS | {"set_alert_action"}:
assert (mcp._tool_manager.get_tool(name).description or "").strip(), name
def test_a_viewer_token_cannot_start_a_run(app):
viewer = Caller(user_id="u", username="v", role=UserRole.viewer, token_id=1)
operator = Caller(user_id="u", username="o", role=UserRole.operator, token_id=2)
assert "needs the operator role" in app.mcp_server.refusal("run_playbook", viewer)
assert app.mcp_server.refusal("run_playbook", operator) is None
assert "needs the admin role" in app.mcp_server.refusal("set_alert_action", operator)
+21
View File
@@ -1,8 +1,11 @@
"""The docker plugin's MCP tools: what counts as a problem, and how it is reported.""" """The docker plugin's MCP tools: what counts as a problem, and how it is reported."""
import inspect
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS from types import SimpleNamespace as NS
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
import plugins.docker as docker_plugin import plugins.docker as docker_plugin
from plugins.docker import mcp_tools as t from plugins.docker import mcp_tools as t
@@ -78,3 +81,21 @@ def test_plugin_exposes_the_mcp_hooks():
for call in reg.call_args_list: for call in reg.call_args_list:
assert call.kwargs["read_only"] is True assert call.kwargs["read_only"] is True
assert callable(docker_plugin.mcp_overview) assert callable(docker_plugin.mcp_overview)
def test_prune_preview_sizes_what_each_target_removes():
usage = NS(images_reclaimable=3000, build_cache_size=70)
assert t.prune_preview("containers", usage, 4) == {"stopped_containers": 4}
assert t.prune_preview("images", usage, 4) == {"unused_images_bytes": 3000}
system = t.prune_preview("system", usage, 4)
assert system["stopped_containers"] == 4 and system["build_cache_bytes"] == 70
assert t.prune_preview("images", None, 0) == {"unused_images_bytes": None}
async def test_prune_docker_refuses_an_unknown_target_before_reading_anything(monkeypatch):
def _no_db():
raise AssertionError("touched the database")
monkeypatch.setattr(t, "db_session", _no_db)
with pytest.raises(ValueError, match="'volumes' is not one of containers, images, system"):
await t.prune_docker("nas", "volumes")
assert inspect.signature(t.prune_docker).parameters["check"].default is True
-11
View File
@@ -41,17 +41,6 @@ def test_container_log_views_defined():
assert callable(r.container_logs_lines) assert callable(r.container_logs_lines)
def test_prune_extra_vars_mapping():
"""The prune buttons drive one playbook via prune_target; only 'images'
widens to ALL unused images (docker image prune -a), system stays -f."""
assert r._prune_extra_vars("containers") == {"prune_target": "containers"}
assert r._prune_extra_vars("images") == {
"prune_target": "images", "prune_all_images": True,
}
# System prune stays conservative — no prune_all_images key.
assert r._prune_extra_vars("system") == {"prune_target": "system"}
def test_human_bytes_formats_binary_units(): def test_human_bytes_formats_binary_units():
assert r._human_bytes(None) == "—" assert r._human_bytes(None) == "—"
assert r._human_bytes(0) == "0 B" assert r._human_bytes(0) == "0 B"
+11
View File
@@ -16,3 +16,14 @@ def test_bucket_keeps_short_series_and_averages_long_ones():
out = svc.bucket([float(i) for i in range(400)], target=40) out = svc.bucket([float(i) for i in range(400)], target=40)
assert len(out) == 40 assert len(out) == 40
assert out[0] == sum(range(10)) / 10 assert out[0] == sum(range(10)) / 10
def test_prune_extra_vars_mapping():
"""The prune buttons drive one playbook via prune_target; only 'images'
widens to ALL unused images (docker image prune -a), system stays -f."""
assert svc.prune_extra_vars("containers") == {"prune_target": "containers"}
assert svc.prune_extra_vars("images") == {
"prune_target": "images", "prune_all_images": True,
}
# System prune stays conservative — no prune_all_images key.
assert svc.prune_extra_vars("system") == {"prune_target": "system"}