#5464 "Group rules: one rule covers every host, or every host in a group", step 2 of milestone 499 "Alerts you can trust". Each rule named one host, so the live instance carried six rules for three hosts, and a new host had no agent-offline or disk alert until someone copied them. - alert_rules.covers / cover_group / parent_id (migration 0041). A rule on host_agent may cover "all_hosts" or a "group" (an inventory group: the Hosts ruling, #5294). A covering rule has no state; Steward keeps one ordinary child rule per covered host, named "<rule>: <host>", so each host's alert keeps its own state, history and acknowledgement. - alerts.service.reconcile_children: a covered host that has reported in the last 7 days gets a child; a host no longer covered loses its child; children take the parent's condition, checks, notify setting, action and on/off. Run on save and each minute (alert_reconcile). - A child is changed through its parent: edit, toggle and delete of a child are refused (409 / PartOfCoveringRule); its edit page opens the parent. - Rule form: a Covers choice for host sources, with the group picker; fixed once the rule exists. Rules list: a covering rule reads "every host in swarm · 3 hosts" with its hosts' rules nested under it, each opening its history. The history picker and the rule count leave covering rules out. - MCP: create_alert_rule takes covers/group; update_alert_rule takes group and passes changes down; list_alert_rules shows covers, hosts and part_of. - docs/core/alerting.md: "Rules that cover hosts". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
7.7 KiB
Python
149 lines
7.7 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 = "!="
|
|
|
|
|
|
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)
|
|
|
|
|
|
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
|
|
)
|