#5748 "MCP can write, edit and test this instance's own playbooks (steward-local)": two admin tools beside run_playbook, so an agent can build automation for these hosts that isn't part of Steward itself. - save_playbook(playbook, content, source="steward-local", replace=false) goes through the editor's own validate_playbook_yaml and write_playbook. A playbook that isn't a list of plays comes back with the YAML error and its line, and nothing is written. An existing file is overwritten only with replace=true. steward-builtin is refused (Steward's playbooks change in its repository), as are git sources (their repo owns them). - After writing, `ansible-playbook --syntax-check` runs against a throwaway localhost inventory (no host contacted, 60 s), so a module name Ansible can't resolve shows before any run. The result rides the reply; the file is kept either way. - delete_playbook(playbook, source="steward-local"). - Both audit as ansible.playbook.created/updated/deleted, "<user> via MCP", with the size and never the content. The testing loop is the existing one: run_playbook defaults to check mode, on one target, then get_ansible_run. The integration test does that loop with the real ansible-playbook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
14 KiB
Python
269 lines
14 KiB
Python
"""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
|
|
# The scope reads as the target's name, not its id.
|
|
assert done["scope"].startswith("steward:target:") and done["scope_label"] == f"target {target_name}"
|
|
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
|
|
assert detail["scope_label"].startswith(f"target {target_name}")
|
|
|
|
# ── 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 and sc["scope_label"] == f"target {target_name}"
|
|
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())
|
|
|
|
|
|
@_NEEDS_DB
|
|
def test_an_instance_playbook_is_written_checked_run_and_removed_through_mcp(app, tmp_path, monkeypatch):
|
|
"""#5748: save_playbook into steward-local, the real syntax check, a
|
|
check-mode run of what was saved, replace, delete; audit rows carry the
|
|
size and never the content."""
|
|
from sqlalchemy import select
|
|
from steward.ansible import sources as src_module
|
|
from steward.core.api_tokens import Caller
|
|
from steward.mcp import _context
|
|
from steward.mcp.tools import automation as a
|
|
from steward.models.ansible_inventory import AnsibleTarget
|
|
from steward.models.audit import AuditEvent
|
|
from steward.models.users import User, UserRole
|
|
|
|
monkeypatch.setattr(src_module, "USER_PLAYBOOK_DIR", str(tmp_path / "local"))
|
|
tag = uuid.uuid4().hex[:8]
|
|
uid, username, target_name = str(uuid.uuid4()), f"mcp-pb-{tag}", f"mcp-pb-target-{tag}"
|
|
marker = "MARKER-" + tag
|
|
good = textwrap.dedent(f"""\
|
|
- hosts: all
|
|
connection: local
|
|
gather_facts: false
|
|
tasks:
|
|
- ansible.builtin.debug:
|
|
msg: "{marker}"
|
|
""")
|
|
typo = good.replace("ansible.builtin.debug", "ansible.builtin.debugg")
|
|
|
|
async def _go():
|
|
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))
|
|
s.add(AnsibleTarget(name=target_name, address="127.0.0.1",
|
|
ansible_vars={"ansible_connection": "local"}))
|
|
_context._app_ctx.set(app)
|
|
_context._caller_ctx.set(Caller(user_id=uid, username=username,
|
|
role=UserRole.admin, token_id=7))
|
|
|
|
bad = await a.save_playbook("upkeep/check.yml", typo)
|
|
assert bad["syntax"]["ok"] is False and "debugg" in bad["syntax"]["output"]
|
|
fixed = await a.save_playbook("upkeep/check.yml", good, replace=True)
|
|
assert fixed["syntax"] == {"ok": True} and fixed["replaced"] is True
|
|
|
|
started = await a.run_playbook("steward-local", "upkeep/check.yml",
|
|
scope=f"target:{target_name}")
|
|
assert started["check"] is True
|
|
done = await a.wait_for_run(started["run_id"], timeout_s=120)
|
|
assert done["status"] == "success", done["tail"]
|
|
assert marker in "\n".join(done["tail"])
|
|
|
|
assert await a.delete_playbook("upkeep/check.yml") == {
|
|
"deleted": "upkeep/check.yml", "source": "steward-local"}
|
|
assert "upkeep/check.yml" not in [
|
|
b["playbook"] for b in (await a.list_playbooks(source="steward-local"))["playbooks"]]
|
|
|
|
async with app.db_sessionmaker() as s:
|
|
rows = list((await s.execute(select(AuditEvent).where(
|
|
AuditEvent.user_id == uid, AuditEvent.action.like("ansible.playbook.%"))
|
|
.order_by(AuditEvent.timestamp))).scalars())
|
|
assert [r.action for r in rows] == ["ansible.playbook.created", "ansible.playbook.updated",
|
|
"ansible.playbook.deleted"]
|
|
assert all(r.username == f"{username} via MCP" for r in rows)
|
|
assert json.loads(rows[1].detail_json)["bytes"] == len(good)
|
|
assert not any(marker in (r.detail_json or "") for r in rows)
|
|
|
|
asyncio.run(_go())
|