#5487 "MCP: edit inventory group membership". Group membership decides which hosts a group alert rule covers, and the MCP could only read it. The new operator tool takes a group and lists to add and remove, each a target's name or id or the Steward host it is linked to; an unknown name refuses the whole change. It answers with the group's targets after and the alert rules covering the group (whose per-host rules follow within a minute), and audits as ansible.group.updated by "<user> via MCP", as the Inventory page does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
81 lines
3.6 KiB
Python
81 lines
3.6 KiB
Python
"""Integration: update_group_members over MCP (#5487) against Postgres. A host
|
|
joins a group by its linked target, a group rule covering that group is named
|
|
in the answer, and the change is audited as "<user> via MCP"."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@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)
|
|
|
|
|
|
def test_a_host_joins_and_leaves_a_group_through_mcp(app):
|
|
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_inventory import AnsibleGroup, AnsibleTarget
|
|
from steward.models.audit import AuditEvent
|
|
from steward.models.hosts import Host
|
|
from steward.models.users import User, UserRole
|
|
|
|
tag = uuid.uuid4().hex[:8]
|
|
uid, username = str(uuid.uuid4()), f"gm-op-{tag}"
|
|
group, host = f"gm-group-{tag}", f"gm-host-{tag}"
|
|
|
|
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.operator, is_active=True))
|
|
h = Host(name=host, address="10.9.9.9")
|
|
s.add(h)
|
|
await s.flush()
|
|
s.add(AnsibleTarget(name=f"gm-target-{tag}", address="10.9.9.9", host_id=h.id))
|
|
s.add(AnsibleTarget(name=f"gm-other-{tag}", address="10.9.9.8"))
|
|
s.add(AnsibleGroup(name=group))
|
|
s.add(AlertRule(name=f"gm-rule-{tag}", source_module="host_agent", resource_name="",
|
|
metric_name="cpu_pct", operator=AlertOperator.gt, threshold=90.0,
|
|
covers="group", cover_group=group, created_by=uid))
|
|
_context._app_ctx.set(app)
|
|
_context._caller_ctx.set(Caller(user_id=uid, username=username,
|
|
role=UserRole.operator, token_id=7))
|
|
|
|
got = await a.update_group_members(group, add=[host, f"gm-other-{tag}"])
|
|
assert got["targets"] == sorted([f"gm-other-{tag}", f"gm-target-{tag}"])
|
|
assert got["added"] == got["targets"] and got["alert_rules"] == [f"gm-rule-{tag}"]
|
|
assert "within a minute" in got["note"]
|
|
inv = await a.list_inventory()
|
|
assert next(t for t in inv["targets"] if t["host_name"] == host)["groups"] == [group]
|
|
|
|
# Adding again changes nothing; an unknown name refuses the lot.
|
|
again = await a.update_group_members(group, add=[f"gm-target-{tag}"])
|
|
assert again["added"] == [] and "note" not in again
|
|
with pytest.raises(ValueError, match="Nothing was changed"):
|
|
await a.update_group_members(group, remove=[host, "no-such-host"])
|
|
|
|
got = await a.update_group_members(group, remove=[host])
|
|
assert got["removed"] == [f"gm-target-{tag}"] and got["targets"] == [f"gm-other-{tag}"]
|
|
|
|
async with app.db_sessionmaker() as s:
|
|
rows = list((await s.execute(select(AuditEvent).where(
|
|
AuditEvent.user_id == uid, AuditEvent.action == "ansible.group.updated")
|
|
.order_by(AuditEvent.timestamp))).scalars())
|
|
assert len(rows) == 2 and rows[0].username == f"{username} via MCP"
|
|
assert json.loads(rows[1].detail_json)["removed"] == [f"gm-target-{tag}"]
|
|
|
|
asyncio.run(_go())
|