#5212 "Plain labels: no raw ids, scopes, metric keys or unexplained jargon", batch 2: - A run records where it came from: AnsibleRun.origin ("schedule", "alert" or "mcp") and origin_id, set by the scheduler, Run now, alert actions and the MCP tools. Migration 0031 backfills past runs from alert events, schedules' last runs, and MCP audit entries. - The runs list has a Started by column: "schedule nightly", "alert rule nas down", "alice via MCP" or "alice", linked where there is a page. The raw scope tooltip is gone. - Run detail: no run id in the subtitle or body, Started by in words, and the host summary lists failed and unreachable hosts first. The live output only follows new lines while the reader is at the bottom, and when the run finishes the page reloads once so the badge and summary are final. - list_ansible_runs and get_ansible_run report origin and origin_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
4.7 KiB
Python
118 lines
4.7 KiB
Python
# steward/ansible/runner.py
|
|
"""Shared playbook-run launcher used by the manual route, alerts, and schedules.
|
|
|
|
Centralises the resolve-inventory → create AnsibleRun → launch executor flow so
|
|
manual, alert-triggered, and scheduled runs all take the exact same path.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
from steward.ansible import executor, guards, sources as src_module
|
|
from steward.ansible.inventory_gen import (
|
|
fetch_scope_targets, generate_inventory, inventory_to_yaml,
|
|
)
|
|
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
|
|
|
|
# A run in one of these hasn't finished: a schedule or an alert action whose
|
|
# previous run is still in one skips its next firing (#5158).
|
|
IN_FLIGHT = (AnsibleRunStatus.queued, AnsibleRunStatus.running)
|
|
|
|
|
|
async def run_in_flight(db, run_id: str | None) -> bool:
|
|
"""Whether run_id names a run that hasn't finished. Runs left running by a
|
|
restart are marked interrupted at startup, so this can't stick."""
|
|
if not run_id:
|
|
return False
|
|
run = await db.get(AnsibleRun, run_id)
|
|
return run is not None and run.status in IN_FLIGHT
|
|
|
|
|
|
async def trigger_run(
|
|
app,
|
|
*,
|
|
source_name: str,
|
|
playbook_path: str,
|
|
inventory_scope: str = "steward:all",
|
|
params: dict | None = None,
|
|
triggered_by: str | None = None,
|
|
origin: str | None = None,
|
|
origin_id: str | None = None,
|
|
inventory_content: str | None = None,
|
|
connection: dict | None = None,
|
|
secret_vars: dict | None = None,
|
|
redact: list[str] | None = None,
|
|
):
|
|
"""Resolve inventory for the scope, persist an AnsibleRun, and launch it.
|
|
|
|
triggered_by=None marks a system/automated run (alerts, schedules).
|
|
origin / origin_id say where it came from (AnsibleRun.origin), so the runs
|
|
list can say "schedule nightly" or "alert rule nas down".
|
|
If inventory_content is provided, it is used verbatim and scope resolution
|
|
is skipped (the caller built a bespoke inventory — e.g. host_agent deploy
|
|
injecting per-host tokens); inventory_scope is still recorded for display.
|
|
connection is an optional per-run SSH override (user/password) for
|
|
first-contact provisioning — passed to the executor but deliberately NOT
|
|
stored on the AnsibleRun row (params), so the password never lands in the DB.
|
|
secret_vars (sensitive run-time playbook variables) are likewise passed
|
|
through to the executor but never persisted. redact names further values
|
|
to mask in the run's output (a bespoke inventory's per-host tokens).
|
|
Returns (run, source, error): on success error is None; on failure run is
|
|
None and error is a short human-readable reason.
|
|
"""
|
|
sources = src_module.get_sources(app.config.get("ANSIBLE", {}))
|
|
source = next((s for s in sources if s["name"] == source_name), None)
|
|
if source is None:
|
|
return None, None, "Source not found"
|
|
# Every run path (web, MCP, alerts, schedules, plugins) arrives here, so
|
|
# this is where a run is held to what its source lists (ansible/guards.py).
|
|
err = guards.check_playbook(source, playbook_path)
|
|
if err is None and inventory_content is None:
|
|
err = guards.check_inventory_scope(source, inventory_scope)
|
|
if err:
|
|
return None, source, err
|
|
|
|
inventory_path: str | None = None
|
|
if inventory_content is not None:
|
|
pass # caller-supplied inventory wins; scope kept only for display
|
|
elif inventory_scope.startswith("steward:"):
|
|
async with app.db_sessionmaker() as db:
|
|
targets = await fetch_scope_targets(db, inventory_scope)
|
|
inventory_content = inventory_to_yaml(generate_inventory(targets))
|
|
else:
|
|
inventory_path = inventory_scope.split(":", 2)[2]
|
|
|
|
run_id = str(uuid.uuid4())
|
|
run = AnsibleRun(
|
|
id=run_id,
|
|
playbook_path=playbook_path,
|
|
inventory_path=inventory_path,
|
|
inventory_scope=inventory_scope,
|
|
source_name=source_name,
|
|
triggered_by=triggered_by,
|
|
origin=origin,
|
|
origin_id=origin_id,
|
|
status=AnsibleRunStatus.running,
|
|
started_at=datetime.now(timezone.utc),
|
|
params=params or None,
|
|
)
|
|
async with app.db_sessionmaker() as db:
|
|
async with db.begin():
|
|
db.add(run)
|
|
|
|
task = asyncio.create_task(
|
|
executor.start_run(
|
|
app, run_id, playbook_path, inventory_path or "",
|
|
source["path"], params or None, inventory_content,
|
|
connection=connection, secret_vars=secret_vars, redact=redact,
|
|
)
|
|
)
|
|
task.add_done_callback(
|
|
lambda t: t.exception() and app.logger.error(
|
|
"Ansible run %s raised: %s", run_id, t.exception()
|
|
)
|
|
)
|
|
return run, source, None
|