feat(alerts): run an Ansible playbook on alert firing (task 250)
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user