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
+8 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -36,6 +36,9 @@ class AlertRule(Base):
threshold: Mapped[float] = mapped_column(Float, nullable=False)
consecutive_failures_required: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Optional admin-only action: run an Ansible playbook on transition-to-firing.
# Shape: {source, playbook, inventory, extra_vars, limit, tags, check}. NULL = none.
ansible_action: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
@@ -97,3 +100,7 @@ class AlertEvent(Base):
email_error: Mapped[str | None] = mapped_column(Text, nullable=True)
webhook_sent: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
webhook_error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Set when this firing event triggered an Ansible action run (audit link).
ansible_run_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("ansible_runs.id", ondelete="SET NULL"), nullable=True
)