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:
@@ -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", "<source>/<inventory file>". `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}
|
||||
|
||||
@@ -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/<run_id>/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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;">
|
||||
<span style="color:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span>
|
||||
<span style="color:var(--text-muted);">Playbook</span><span>{{ run.playbook_path }}</span>
|
||||
<span style="color:var(--text-muted);">Scope</span><span style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ run.inventory_scope }}</span>
|
||||
<span style="color:var(--text-muted);">Scope</span><span title="{{ run.inventory_scope }}">{{ scope_label or run.inventory_scope }}</span>
|
||||
<span style="color:var(--text-muted);">Source</span><span>{{ run.source_name }}</span>
|
||||
<span style="color:var(--text-muted);">Triggered by</span><span>{{ triggered_label }}</span>
|
||||
<span style="color:var(--text-muted);">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<tr style="{% if not s.enabled %}opacity:0.55;{% endif %}">
|
||||
<td style="font-weight:500;">{{ s.name }}</td>
|
||||
<td style="font-size:0.85rem;"><span style="color:var(--text-dim);">{{ s.source_name }}</span> / {{ s.playbook_path }}</td>
|
||||
<td style="font-size:0.82rem;color:var(--text-muted);font-family:ui-monospace,monospace;">{{ s.inventory_scope }}</td>
|
||||
<td style="font-size:0.85rem;" title="{{ s.inventory_scope }}">{{ row.scope_label }}</td>
|
||||
<td style="font-size:0.85rem;">{{ fmt_interval(s.interval_seconds) }}</td>
|
||||
<td style="font-size:0.82rem;">
|
||||
{% if s.last_error %}
|
||||
|
||||
Reference in New Issue
Block a user