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:<uuid>"
  into "target web-1", "steward:group:<uuid>" 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:29:06 -04:00
co-authored by Claude Opus 5.5
parent 30cb375079
commit 06e89308a8
7 changed files with 74 additions and 12 deletions
+16 -8
View File
@@ -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,