feat(mcp): write, test and delete this instance's own playbooks (#5748)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m29s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m6s

#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>
This commit is contained in:
2026-10-15 12:01:39 -04:00
co-authored by Claude Opus 5.5
parent 39685585cd
commit e122860cdc
6 changed files with 299 additions and 6 deletions
+69
View File
@@ -197,3 +197,72 @@ def test_runs_schedules_and_alert_actions_through_mcp(app, tmp_path):
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())