feat(mcp): update_group_members puts hosts into an inventory group or takes them out (#5487)
#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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Automation tools: playbooks, inventory, runs, schedules and alert actions.
|
||||
"""Automation tools: playbooks, inventory and its groups, runs, schedules and
|
||||
alert actions.
|
||||
|
||||
Reads are viewer tools. Anything that starts a run or changes what runs is an
|
||||
operator tool (set_alert_action is admin, as on the alert rule form), and each
|
||||
@@ -267,6 +268,77 @@ async def list_inventory() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def member_targets(names: list[str] | None, targets: list, host_names: dict[str, str]) -> list:
|
||||
"""The targets `names` mean, each a target's name or id, or the name of the
|
||||
Steward host a target is linked to. Refuses the whole list when one is
|
||||
unknown, so nothing changes on a typo."""
|
||||
by_host = {host_names[t.host_id].lower(): t for t in targets
|
||||
if t.host_id and host_names.get(t.host_id)}
|
||||
found, unknown = [], []
|
||||
for want in names or []:
|
||||
want = (want or "").strip()
|
||||
if not want:
|
||||
continue
|
||||
t = next((t for t in targets if t.id == want or t.name.lower() == want.lower()), None) \
|
||||
or by_host.get(want.lower())
|
||||
(found if t else unknown).append(t or want)
|
||||
if unknown:
|
||||
raise ValueError(f"no target or linked host named {', '.join(map(repr, unknown))}; "
|
||||
"list_inventory lists them. Nothing was changed.")
|
||||
return list({t.id: t for t in found}.values())
|
||||
|
||||
|
||||
async def update_group_members(group: str, add: list[str] | None = None,
|
||||
remove: list[str] | None = None) -> dict:
|
||||
"""Put targets into an inventory group, or take them out.
|
||||
|
||||
Inventory groups are Steward's grouping system: an alert rule covering a
|
||||
group watches every host in it, the Hosts list filters by them, and a run
|
||||
can be scoped to one. A host is in a group through its linked target.
|
||||
|
||||
group: the group's name or id (list_inventory).
|
||||
add, remove: targets by name or id, or by the name of the Steward host a
|
||||
target is linked to. One already in (or out of) the group is left as it
|
||||
is; an unknown name refuses the whole change.
|
||||
|
||||
Returns the group's targets after the change and the alert rules that
|
||||
cover it; their per-host rules follow within a minute. Recorded in the
|
||||
audit log as "<you> via MCP".
|
||||
"""
|
||||
if not (add or remove):
|
||||
raise ValueError("pass add and/or remove: the targets to put in or take out.")
|
||||
async with db_session() as db, db.begin():
|
||||
g = pick(group, (await db.execute(
|
||||
select(AnsibleGroup).options(selectinload(AnsibleGroup.targets)))).scalars(),
|
||||
kind="group", list_tool="list_inventory")
|
||||
targets = list((await db.execute(select(AnsibleTarget))).scalars())
|
||||
host_names = dict((await db.execute(select(Host.id, Host.name))).all())
|
||||
to_add = member_targets(add, targets, host_names)
|
||||
to_remove = member_targets(remove, targets, host_names)
|
||||
both = {t.name for t in to_add} & {t.name for t in to_remove}
|
||||
if both:
|
||||
raise ValueError(f"{', '.join(sorted(both))} is in both add and remove. Nothing was changed.")
|
||||
have = {t.id for t in g.targets}
|
||||
added = [t for t in to_add if t.id not in have]
|
||||
removed = [t for t in to_remove if t.id in have]
|
||||
g.targets = [t for t in g.targets if t.id not in {r.id for r in removed}] + added
|
||||
members = sorted(t.name for t in g.targets)
|
||||
covering = sorted((await db.execute(select(AlertRule.name).where(
|
||||
AlertRule.covers == "group", AlertRule.cover_group == g.name))).scalars())
|
||||
name, gid = g.name, g.id
|
||||
if added or removed:
|
||||
# The Inventory page's own record of a group's membership changing.
|
||||
await audit_mcp("ansible.group.updated", "ansible_group", gid,
|
||||
{"name": name, "targets": members,
|
||||
"added": sorted(t.name for t in added),
|
||||
"removed": sorted(t.name for t in removed)})
|
||||
return {"group": name, "group_id": gid,
|
||||
"added": sorted(t.name for t in added), "removed": sorted(t.name for t in removed),
|
||||
"targets": members, "alert_rules": covering,
|
||||
**({"note": "Alert rules covering this group pick up the change within a minute."}
|
||||
if covering and (added or removed) else {})}
|
||||
|
||||
|
||||
# ── runs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def recap_totals(results: dict | None) -> dict | None:
|
||||
@@ -712,6 +784,7 @@ def register(register_tool) -> None:
|
||||
for fn in (list_playbooks, get_playbook, list_inventory, list_ansible_runs,
|
||||
get_ansible_run, list_schedules, wait_for_run):
|
||||
register_tool(fn, role=UserRole.viewer, read_only=True)
|
||||
for fn in (run_playbook, cancel_ansible_run, create_schedule, update_schedule, delete_schedule):
|
||||
for fn in (run_playbook, cancel_ansible_run, create_schedule, update_schedule, delete_schedule,
|
||||
update_group_members):
|
||||
register_tool(fn, role=UserRole.operator, read_only=False)
|
||||
register_tool(set_alert_action, role=UserRole.admin, read_only=False)
|
||||
|
||||
Reference in New Issue
Block a user