Files
FabledSteward/steward/models/alerts.py
T
bvandeusenandClaude Opus 5.5 e51d969c43 feat(alerts): a rule can fire when a reading rises, "rose by N" (#6724)
A rule compared a reading with a fixed number, so Synology Disk 1's bad
sectors going from 3,160 to 3,392 could say nothing: a count that only grows
is soon past any line. "rose by" compares each reading with the one before it,
kept on the alert's state and only for such a rule. A fall (a replaced disk)
doesn't fire. On the rule form, through MCP, and worded in emails, the alert
sentence, history and the timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:03:29 -04:00

159 lines
8.2 KiB
Python

from __future__ import annotations
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class AlertOperator(str, enum.Enum):
gt = ">"
lt = "<"
gte = ">="
lte = "<="
eq = "=="
ne = "!="
# The reading against the one before it, not against a fixed line: for a
# count that only grows, where any fixed line is soon behind it.
rose = "rose"
@property
def said(self) -> str:
"""The operator as a condition reads: "> 90", "rose by 1"."""
return "rose by" if self is AlertOperator.rose else self.value
class AlertStateEnum(str, enum.Enum):
inactive = "inactive"
pending = "pending"
firing = "firing"
acknowledged = "acknowledged"
resolved = "resolved"
class AlertRule(Base):
__tablename__ = "alert_rules"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(128), nullable=False)
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
# The DB enum (migration 0002) stores the operator VALUES (">", "<", …), so
# persist values rather than SQLAlchemy's default enum-member NAMES ("gt"…).
operator: Mapped[AlertOperator] = mapped_column(
Enum(AlertOperator, name="alertoperator",
values_callable=lambda e: [m.value for m in e]),
nullable=False,
)
threshold: Mapped[float] = mapped_column(Float, nullable=False)
consecutive_failures_required: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
# Non-breaching checks in a row before a firing (or acknowledged) alert resolves.
consecutive_ok_required: Mapped[int] = mapped_column(Integer, nullable=False, default=1,
server_default="1")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# Notify when the watched series goes silent (no_data) and when it resumes.
notify_no_data: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True,
server_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)
)
# What the rule covers (#5464): "resource" watches resource_name itself;
# "all_hosts" and "group" make it a covering rule over hosts (every host,
# or every host in the inventory group cover_group). A covering rule has
# no state and is never evaluated: Steward keeps one ordinary child rule
# per covered host (parent_id), so each host's alert keeps its own state,
# history and acknowledgement. Fixed when the rule is created.
covers: Mapped[str] = mapped_column(String(16), nullable=False, default="resource",
server_default="resource")
cover_group: Mapped[str | None] = mapped_column(String(128), nullable=True)
parent_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("alert_rules.id", ondelete="CASCADE"), nullable=True, index=True
)
class AlertState(Base):
__tablename__ = "alert_states"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
rule_id: Mapped[str] = mapped_column(
String(36), ForeignKey("alert_rules.id", ondelete="CASCADE"), nullable=False, unique=True
)
state: Mapped[AlertStateEnum] = mapped_column(
Enum(AlertStateEnum), nullable=False, default=AlertStateEnum.inactive
)
consecutive_failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
consecutive_ok_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0,
server_default="0")
last_transition_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
acknowledged_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Silence tracking (core.alerts.check_silence): when the watched series last
# had a sample, its usual gap between samples, and since when it has had
# none for too long. no_data_since is a mark beside the state, not a state:
# a firing alert that goes silent stays firing.
last_sample_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
sample_interval_s: Mapped[float | None] = mapped_column(Float, nullable=True)
no_data_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# The reading before this one, kept only for a rule that fires on a rise.
last_value: Mapped[float | None] = mapped_column(Float, nullable=True)
class MaintenanceWindow(Base):
__tablename__ = "maintenance_windows"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(128), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Scope — null means "all". scope_resource requires scope_source to be set.
scope_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
scope_resource: Mapped[str | None] = mapped_column(String(255), 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)
)
class AlertEvent(Base):
__tablename__ = "alert_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
rule_id: Mapped[str] = mapped_column(
String(36), ForeignKey("alert_rules.id", ondelete="CASCADE"), nullable=False
)
from_state: Mapped[str] = mapped_column(String(32), nullable=False)
to_state: Mapped[str] = mapped_column(String(32), nullable=False)
# None when the transition came from a change to the rule, not a sample.
metric_value: Mapped[float | None] = mapped_column(Float, nullable=True)
# Why a transition happened when the value alone doesn't say (a rule edit).
note: Mapped[str | None] = mapped_column(Text, nullable=True)
transitioned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
email_sent: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
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)
# Who made this transition happen, when a person did: an acknowledgement,
# or a rule edit, disable or enable that reset the alert (#5463). None for
# every transition a sample or Steward itself caused.
actor_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), 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
)