feat(mcp): automation tools — playbooks, runs, schedules, alert actions, docker prune
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:
@@ -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())
|
||||
@@ -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)
|
||||
@@ -1,8 +1,11 @@
|
||||
"""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 types import SimpleNamespace as NS
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.docker as docker_plugin
|
||||
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:
|
||||
assert call.kwargs["read_only"] is True
|
||||
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
|
||||
|
||||
@@ -41,17 +41,6 @@ def test_container_log_views_defined():
|
||||
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():
|
||||
assert r._human_bytes(None) == "—"
|
||||
assert r._human_bytes(0) == "0 B"
|
||||
|
||||
@@ -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)
|
||||
assert len(out) == 40
|
||||
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"}
|
||||
|
||||
Reference in New Issue
Block a user