feat(mcp): write, test and delete this instance's own playbooks (#5748)
#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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user