From 06e89308a86a899c120a9db2de4dbda7c45dd42b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 10:29:06 -0400 Subject: [PATCH] feat(ansible): a run's scope reads as the target or group it names #5014 "Ansible run scope is shown as a raw id" - inventory_gen.describe_scope / scope_labels turn "steward:target:" into "target web-1", "steward:group:" into "group Docker Swarm", and a deleted one into "target abcdef01 (deleted)". - MCP run and schedule rows carry scope_label beside the stored scope; the ansible.run.started audit detail records it too. - The web run page and schedules table show the name, with the stored scope as the tooltip. Co-Authored-By: Claude Opus 5.5 --- steward/ansible/inventory_gen.py | 36 +++++++++++++++++++++++ steward/ansible/routes.py | 7 ++++- steward/mcp/tools/automation.py | 24 ++++++++++----- steward/templates/ansible/run_detail.html | 2 +- steward/templates/ansible/schedules.html | 2 +- tests/core/test_inventory_gen.py | 10 +++++++ tests/integration/test_mcp_automation.py | 5 +++- 7 files changed, 74 insertions(+), 12 deletions(-) diff --git a/steward/ansible/inventory_gen.py b/steward/ansible/inventory_gen.py index 2938625..15c50e9 100644 --- a/steward/ansible/inventory_gen.py +++ b/steward/ansible/inventory_gen.py @@ -106,3 +106,39 @@ async def fetch_scope_targets(db, scope: str) -> list: result = await db.execute(stmt) return list(result.scalars().all()) + + +def describe_scope(scope: str, targets: dict[str, str], groups: dict[str, str]) -> str: + """A run's inventory scope in words: "target web-1", "group Docker Swarm", + "all inventory targets", "/". `targets` and + `groups` map id → name; a scope naming a deleted one says so.""" + scope = scope or "" + if scope == "steward:all": + return "all inventory targets" + for prefix, kind, names in (("steward:target:", "target", targets), + ("steward:group:", "group", groups)): + if scope.startswith(prefix): + ident = scope[len(prefix):] + name = names.get(ident) + return f"{kind} {name}" if name else f"{kind} {ident[:8]} (deleted)" + if scope.startswith("repo:"): + source, _, inv = scope[len("repo:"):].rpartition(":") + return f"{source}/{inv}" if source else inv + return scope + + +async def scope_labels(db, scopes) -> dict[str, str]: + """{scope: describe_scope(scope)} for the given scopes, in two queries.""" + from sqlalchemy import select + from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget + + scopes = {s for s in scopes if s} + t_ids = {s[len("steward:target:"):] for s in scopes if s.startswith("steward:target:")} + g_ids = {s[len("steward:group:"):] for s in scopes if s.startswith("steward:group:")} + targets = dict((await db.execute( + select(AnsibleTarget.id, AnsibleTarget.name).where(AnsibleTarget.id.in_(t_ids)) + )).all()) if t_ids else {} + groups = dict((await db.execute( + select(AnsibleGroup.id, AnsibleGroup.name).where(AnsibleGroup.id.in_(g_ids)) + )).all()) if g_ids else {} + return {s: describe_scope(s, targets, groups) for s in scopes} diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index 5217b84..9f368e6 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -9,6 +9,7 @@ from quart import ( from sqlalchemy import select from steward.ansible import executor, sources as src_module +from steward.ansible.inventory_gen import scope_labels from steward.auth.middleware import require_role from steward.models.ansible import AnsibleRun, AnsibleRunStatus from steward.models.ansible_schedule import AnsibleSchedule @@ -306,8 +307,10 @@ async def run_detail(run_id: str): res = await db.execute( select(User.username).where(User.id == run.triggered_by)) triggered_label = res.scalar_one_or_none() or "(deleted user)" + scope_label = (await scope_labels(db, [run.inventory_scope])).get(run.inventory_scope) return await render_template( - "ansible/run_detail.html", run=run, triggered_label=triggered_label) + "ansible/run_detail.html", run=run, triggered_label=triggered_label, + scope_label=scope_label) @ansible_bp.post("/runs//cancel") @@ -368,6 +371,7 @@ async def schedules(): r.id: r for r in (await db.execute( select(AnsibleRun).where(AnsibleRun.id.in_(run_ids)))).scalars().all() } if run_ids else {} + labels = await scope_labels(db, [s.inventory_scope for s in scheds]) source_data = [ {"name": s["name"], "playbooks": src_module.discover_playbooks(s["path"])} @@ -380,6 +384,7 @@ async def schedules(): rows.append({ "s": s, "last_run": runs.get(s.last_run_id) if s.last_run_id else None, + "scope_label": labels.get(s.inventory_scope, s.inventory_scope), "next_run": (s.last_run_at + timedelta(seconds=s.interval_seconds)) if s.last_run_at else None, }) if editing_id and s.id == editing_id: diff --git a/steward/mcp/tools/automation.py b/steward/mcp/tools/automation.py index b6cd10a..e717881 100644 --- a/steward/mcp/tools/automation.py +++ b/steward/mcp/tools/automation.py @@ -20,6 +20,7 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from steward.ansible import executor, sources as src_module +from steward.ansible.inventory_gen import scope_labels from steward.ansible.runner import trigger_run from steward.mcp._context import current_caller, mcp_app from steward.models.alerts import AlertRule @@ -240,13 +241,15 @@ def recap_totals(results: dict | None) -> dict | None: **{k: sum(int((h or {}).get(k) or 0) for h in hosts.values()) for k in RECAP_KEYS}} -def run_row(r: AnsibleRun, users: dict) -> dict: +def run_row(r: AnsibleRun, users: dict, labels: dict) -> dict: + """`labels` is scope_labels() for the runs' scopes.""" end = r.finished_at return { "run_id": r.id, "source": r.source_name, "playbook": r.playbook_path, "scope": r.inventory_scope, + "scope_label": labels.get(r.inventory_scope, r.inventory_scope), "status": r.status.value, "check": bool((r.params or {}).get("check")), "triggered_by": users.get(r.triggered_by, "(deleted user)") if r.triggered_by else "system", @@ -293,7 +296,8 @@ async def list_ansible_runs(status: str = "", playbook: str = "", range: str = " async with db_session() as db: runs = list((await db.execute(stmt)).scalars()) users = await _users(db, [r.triggered_by for r in runs]) - out = {"range": key, **bounded([run_row(r, users) for r in runs], limit, key="runs")} + labels = await scope_labels(db, [r.inventory_scope for r in runs]) + out = {"range": key, **bounded([run_row(r, users, labels) for r in runs], limit, key="runs")} if out["truncated"]: out["total"] = None out["more"] = (f"More than {limit} runs in range. Pass a larger `limit` (max 500), " @@ -333,9 +337,10 @@ async def _run_detail(run_id: str, tail: int, grep: str) -> dict: async with db_session() as db: run = await _load_run(db, run_id) users = await _users(db, [run.triggered_by]) + labels = await scope_labels(db, [run.inventory_scope]) params = dict(run.params or {}) return { - **run_row(run, users), + **run_row(run, users, labels), "limit": params.get("limit"), "tags": params.get("tags"), "extra_vars": params.get("extra_vars_map") or {}, @@ -418,7 +423,7 @@ async def run_playbook(source: str, playbook: str, scope: str = "all", raise ValueError(f"the run could not start: {err}.") await audit_mcp("ansible.run.started", "ansible_run", run.id, { "source": s["name"], "playbook": playbook.strip(), "scope": stored_scope, - "check": check, "limit": params.get("limit"), "tags": params.get("tags"), + "scope_label": scope_desc, "check": check, "limit": params.get("limit"), "tags": params.get("tags"), "extra_vars": sorted(plain), "secret_vars": sorted(secret)}) return { "run_id": run.id, @@ -447,7 +452,7 @@ async def cancel_ansible_run(run_id: str) -> dict: # ── schedules ──────────────────────────────────────────────────────────────── -def schedule_row(sc: AnsibleSchedule, last: AnsibleRun | None) -> dict: +def schedule_row(sc: AnsibleSchedule, last: AnsibleRun | None, labels: dict) -> dict: params = sc.params or {} nxt = sc.last_run_at + timedelta(seconds=sc.interval_seconds) if sc.last_run_at else None return { @@ -456,6 +461,7 @@ def schedule_row(sc: AnsibleSchedule, last: AnsibleRun | None) -> dict: "source": sc.source_name, "playbook": sc.playbook_path, "scope": sc.inventory_scope, + "scope_label": labels.get(sc.inventory_scope, sc.inventory_scope), "interval_s": sc.interval_seconds, "enabled": sc.enabled, "check": bool(params.get("check")), @@ -487,7 +493,8 @@ async def list_schedules() -> dict: ids = [s.last_run_id for s in scheds if s.last_run_id] runs = {r.id: r for r in (await db.execute( select(AnsibleRun).where(AnsibleRun.id.in_(ids)))).scalars()} if ids else {} - rows = [schedule_row(s, runs.get(s.last_run_id)) for s in scheds] + labels = await scope_labels(db, [s.inventory_scope for s in scheds]) + rows = [schedule_row(s, runs.get(s.last_run_id), labels) for s in scheds] return {"schedules": rows, "total": len(rows)} @@ -531,10 +538,11 @@ async def create_schedule(name: str, source: str, playbook: str, interval_s: int sc = AnsibleSchedule(name=name.strip()[:128], interval_seconds=_interval(interval_s), enabled=enabled, **fields) db.add(sc) + labels = await scope_labels(db, [sc.inventory_scope]) await audit_mcp("ansible.schedule.created", "ansible_schedule", sc.id, {"name": sc.name, "playbook": sc.playbook_path, "interval_s": sc.interval_seconds, "check": check}) - return schedule_row(sc, None) + return schedule_row(sc, None, labels) async def update_schedule(schedule: str, name: str | None = None, source: str | None = None, @@ -569,7 +577,7 @@ async def update_schedule(schedule: str, name: str | None = None, source: str | sc.interval_seconds = _interval(interval_s) if enabled is not None: sc.enabled = enabled - row = schedule_row(sc, None) + row = schedule_row(sc, None, await scope_labels(db, [sc.inventory_scope])) changed = sorted(k for k, v in {"name": name, "source": source, "playbook": playbook, "interval_s": interval_s, "scope": scope, "extra_vars": extra_vars, "limit": limit, "tags": tags, "check": check, diff --git a/steward/templates/ansible/run_detail.html b/steward/templates/ansible/run_detail.html index ec0b06f..0c5ebd6 100644 --- a/steward/templates/ansible/run_detail.html +++ b/steward/templates/ansible/run_detail.html @@ -20,7 +20,7 @@
ID{{ run.id }} Playbook{{ run.playbook_path }} - Scope{{ run.inventory_scope }} + Scope{{ scope_label or run.inventory_scope }} Source{{ run.source_name }} Triggered by{{ triggered_label }} Status{{ run.status.value.upper() }} diff --git a/steward/templates/ansible/schedules.html b/steward/templates/ansible/schedules.html index bf53e42..8a6d4be 100644 --- a/steward/templates/ansible/schedules.html +++ b/steward/templates/ansible/schedules.html @@ -35,7 +35,7 @@ {{ s.name }} {{ s.source_name }} / {{ s.playbook_path }} - {{ s.inventory_scope }} + {{ row.scope_label }} {{ fmt_interval(s.interval_seconds) }} {% if s.last_error %} diff --git a/tests/core/test_inventory_gen.py b/tests/core/test_inventory_gen.py index b0b1811..bf2e357 100644 --- a/tests/core/test_inventory_gen.py +++ b/tests/core/test_inventory_gen.py @@ -128,3 +128,13 @@ def test_yaml_injected_hostvars_survive(): def test_yaml_empty_targets(): parsed = yaml.safe_load(inventory_to_yaml(generate_inventory([]))) assert parsed == {"all": {"hosts": {}}} + + +def test_describe_scope_names_what_a_run_targeted(): + from steward.ansible.inventory_gen import describe_scope + targets, groups = {"t1": "web-1"}, {"g1": "Docker Swarm"} + assert describe_scope("steward:all", targets, groups) == "all inventory targets" + assert describe_scope("steward:target:t1", targets, groups) == "target web-1" + assert describe_scope("steward:group:g1", targets, groups) == "group Docker Swarm" + assert describe_scope("steward:target:abcdef0123", targets, groups) == "target abcdef01 (deleted)" + assert describe_scope("repo:homelab:hosts.yml", targets, groups) == "homelab/hosts.yml" diff --git a/tests/integration/test_mcp_automation.py b/tests/integration/test_mcp_automation.py index 8cf58c5..845c041 100644 --- a/tests/integration/test_mcp_automation.py +++ b/tests/integration/test_mcp_automation.py @@ -121,6 +121,8 @@ def test_runs_schedules_and_alert_actions_through_mcp(app, tmp_path): 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 @@ -134,6 +136,7 @@ def test_runs_schedules_and_alert_actions_through_mcp(app, tmp_path): 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"): @@ -166,7 +169,7 @@ def test_runs_schedules_and_alert_actions_through_mcp(app, tmp_path): 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 + 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