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:
@@ -4,6 +4,7 @@ from quart import Blueprint, render_template, request, redirect, url_for, curren
|
||||
from steward.core.audit import log_audit
|
||||
from sqlalchemy import select
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.ansible import sources as src_module
|
||||
from steward.models.alerts import AlertRule, AlertState, AlertStateEnum, AlertOperator, MaintenanceWindow
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.metrics import PluginMetric
|
||||
@@ -56,6 +57,61 @@ async def _get_resources(db, source_module: str) -> list[str]:
|
||||
return sorted(resources)
|
||||
|
||||
|
||||
def _is_admin() -> bool:
|
||||
return session.get("user_role") == UserRole.admin.value
|
||||
|
||||
|
||||
def _ansible_source_names() -> list[str]:
|
||||
try:
|
||||
return [s["name"] for s in src_module.get_sources(current_app.config.get("ANSIBLE", {}))]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _parse_ansible_action(form) -> tuple[dict | None, str | None]:
|
||||
"""Parse + validate the admin-only Ansible action from a rule form.
|
||||
|
||||
Returns (action_or_None, error_or_None). action is None when the action
|
||||
checkbox is unchecked (i.e. the action is being cleared).
|
||||
"""
|
||||
if "ansible_enabled" not in form:
|
||||
return None, None
|
||||
source_name = (form.get("ansible_source", "") or "").strip()
|
||||
playbook = (form.get("ansible_playbook", "") or "").strip()
|
||||
inventory = (form.get("ansible_inventory", "") or "").strip()
|
||||
if not (source_name and playbook and inventory):
|
||||
return None, "Ansible action requires source, playbook, and inventory"
|
||||
try:
|
||||
srcs = src_module.get_sources(current_app.config.get("ANSIBLE", {}))
|
||||
except Exception:
|
||||
return None, "Ansible sources are misconfigured"
|
||||
source = next((s for s in srcs if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return None, f"Ansible source '{source_name}' not found"
|
||||
if playbook not in src_module.discover_playbooks(source["path"]):
|
||||
return None, f"Playbook '{playbook}' not found in source '{source_name}'"
|
||||
extra_vars: list[str] = []
|
||||
for line in (form.get("ansible_extra_vars", "") or "").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "=" not in line:
|
||||
return None, f"Invalid extra var (expected key=value): {line!r}"
|
||||
extra_vars.append(line)
|
||||
action: dict = {"source": source_name, "playbook": playbook, "inventory": inventory}
|
||||
if extra_vars:
|
||||
action["extra_vars"] = extra_vars
|
||||
limit = (form.get("ansible_limit", "") or "").strip()
|
||||
tags = (form.get("ansible_tags", "") or "").strip()
|
||||
if limit:
|
||||
action["limit"] = limit
|
||||
if tags:
|
||||
action["tags"] = tags
|
||||
if "ansible_check" in form:
|
||||
action["check"] = True
|
||||
return action, None
|
||||
|
||||
|
||||
@alerts_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def list_alerts():
|
||||
@@ -120,6 +176,7 @@ async def new_rule():
|
||||
initial_source=first_source,
|
||||
resources=resources,
|
||||
metrics=METRIC_CATALOG.get(first_source, []),
|
||||
ansible_sources=_ansible_source_names(),
|
||||
)
|
||||
|
||||
|
||||
@@ -140,6 +197,7 @@ async def edit_rule(rule_id: str):
|
||||
initial_source=rule.source_module,
|
||||
resources=resources,
|
||||
metrics=METRIC_CATALOG.get(rule.source_module, []),
|
||||
ansible_sources=_ansible_source_names(),
|
||||
)
|
||||
|
||||
|
||||
@@ -149,6 +207,12 @@ async def create_rule():
|
||||
form = await request.form
|
||||
user_id = session.get("user_id")
|
||||
now = datetime.now(timezone.utc)
|
||||
# Ansible action is admin-only; non-admins simply can't set one.
|
||||
ansible_action = None
|
||||
if _is_admin():
|
||||
ansible_action, err = _parse_ansible_action(form)
|
||||
if err:
|
||||
return err, 400
|
||||
rule = AlertRule(
|
||||
name=form["name"].strip(),
|
||||
source_module=form["source_module"].strip(),
|
||||
@@ -158,6 +222,7 @@ async def create_rule():
|
||||
threshold=float(form["threshold"]),
|
||||
consecutive_failures_required=int(form.get("consecutive_failures_required") or 1),
|
||||
enabled=True,
|
||||
ansible_action=ansible_action,
|
||||
created_by=user_id,
|
||||
)
|
||||
state = AlertState(
|
||||
@@ -182,6 +247,13 @@ async def create_rule():
|
||||
@require_role(UserRole.operator)
|
||||
async def update_rule(rule_id: str):
|
||||
form = await request.form
|
||||
# Validate the admin-only action before opening the transaction. Non-admins
|
||||
# leave the existing action untouched (they can still edit everything else).
|
||||
apply_action = _is_admin()
|
||||
if apply_action:
|
||||
ansible_action, err = _parse_ansible_action(form)
|
||||
if err:
|
||||
return err, 400
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
@@ -195,6 +267,8 @@ async def update_rule(rule_id: str):
|
||||
rule.operator = AlertOperator(form["operator"])
|
||||
rule.threshold = float(form["threshold"])
|
||||
rule.consecutive_failures_required = int(form.get("consecutive_failures_required") or 1)
|
||||
if apply_action:
|
||||
rule.ansible_action = ansible_action
|
||||
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
|
||||
"alert_rule.updated", entity_type="alert_rule", entity_id=rule.name)
|
||||
return redirect(url_for("alerts.list_alerts"))
|
||||
|
||||
Reference in New Issue
Block a user