"""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 " 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"] == ("", 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())