feat(alerts): run an Ansible playbook on alert firing (task 250)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Failing after 2m16s

Rebuilds the deleted NUT/UPS automation as a general alert action: any metric
can drive a playbook run on transition-to-firing.

- models/alerts.py + migration 0014: AlertRule.ansible_action (JSON, admin-only,
  reuses the #546 param shape); AlertEvent.ansible_run_id links a firing event to
  the run it triggered
- core/alerts.py: pure alert_extra_vars() injects steward_alert_* context; on
  ('firing', event) with an action set, schedule _run_ansible_action (deferred
  after commit, same pattern as notifications) — fires once per transition,
  consecutive_failures_required is the debounce; system-triggered AnsibleRun
- alerts/routes.py: admin-only parse/validate of the action (source must be
  configured, playbook must exist); operators keep editing rules, action preserved
- rules_form.html: admin-only 'On firing -> run a playbook' section
- tests: unit for alert_extra_vars; integration drives record_metric to firing
  and asserts a system AnsibleRun ran the playbook with the injected var and the
  event linked to it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:22:29 -04:00
parent 8b62eb2ca3
commit 4771d17f6d
7 changed files with 379 additions and 1 deletions
+86
View File
@@ -32,6 +32,23 @@ def init_alerts(app: "Quart") -> None:
_app = app
def alert_extra_vars(rule: AlertRule, value: float) -> list[str]:
"""Ansible extra-vars describing the alert that fired, as `key=value` strings.
Injected into every alert-triggered playbook run so the playbook can target
the exact resource/metric that fired without templating. Pure function.
"""
return [
f"steward_alert_rule={rule.name}",
f"steward_alert_source={rule.source_module}",
f"steward_alert_resource={rule.resource_name}",
f"steward_alert_metric={rule.metric_name}",
f"steward_alert_value={value}",
f"steward_alert_threshold={rule.threshold}",
f"steward_alert_operator={rule.operator.value}",
]
async def record_metric(
session: AsyncSession,
source_module: str,
@@ -110,6 +127,11 @@ async def record_metric(
_app, notif_type, rule, value, event_id
)
)
# Admin-configured Ansible action runs only on transition-to-firing.
if notif_type == "firing" and rule.ansible_action:
asyncio.create_task(
_run_ansible_action(_app, rule, value, event_id)
)
def _is_breached(value: float, operator: AlertOperator, threshold: float) -> bool:
@@ -259,3 +281,67 @@ async def _dispatch_notification(
if webhook_r:
event.webhook_sent = webhook_r["sent"]
event.webhook_error = webhook_r.get("error")
async def _run_ansible_action(
app: "Quart",
rule: AlertRule,
value: float,
event_id: str,
) -> None:
"""Run the rule's configured Ansible playbook (system-triggered).
Runs after the evaluation transaction commits (scheduled via create_task).
Creates a system-triggered AnsibleRun, links it to the firing AlertEvent,
then executes it with the static params plus injected alert context vars.
"""
from steward.ansible import executor, sources as src_module
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
action = rule.ansible_action or {}
playbook = action.get("playbook")
inventory = action.get("inventory")
if not playbook or not inventory:
logger.error("Ansible action for rule %r missing playbook/inventory", rule.name)
return
try:
srcs = src_module.get_sources(app.config.get("ANSIBLE", {}))
except Exception:
logger.exception("Ansible action for rule %r: could not load sources", rule.name)
return
source = next((s for s in srcs if s["name"] == action.get("source")), None)
if source is None:
logger.error("Ansible action for rule %r: source %r not configured",
rule.name, action.get("source"))
return
# Static params + auto-injected alert context (all argv, never shell).
params: dict = {
"extra_vars": list(action.get("extra_vars") or []) + alert_extra_vars(rule, value),
}
if action.get("limit"):
params["limit"] = action["limit"]
if action.get("tags"):
params["tags"] = action["tags"]
if action.get("check"):
params["check"] = True
run_id = str(uuid.uuid4())
async with app.db_sessionmaker() as db:
async with db.begin():
db.add(AnsibleRun(
id=run_id,
playbook_path=playbook,
inventory_path=inventory,
source_name=source["name"],
triggered_by=None,
status=AnsibleRunStatus.running,
params=params,
))
event = (await db.execute(
select(AlertEvent).where(AlertEvent.id == event_id))).scalar_one_or_none()
if event:
event.ansible_run_id = run_id
await executor.start_run(app, run_id, playbook, inventory, source["path"], params)