This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type Alert struct {
|
||||
Instance
|
||||
RuleName string `json:"ruleName"`
|
||||
Severity string `json:"severity"`
|
||||
EntityType string `json:"entityType,omitempty"`
|
||||
EntityName string `json:"entityName,omitempty"`
|
||||
Occurrences []Occurrence `json:"occurrences,omitempty"`
|
||||
}
|
||||
|
||||
type AlertReader interface {
|
||||
ListAlerts(context.Context, int, string) ([]Alert, error)
|
||||
GetAlert(context.Context, string, int) (Alert, error)
|
||||
}
|
||||
|
||||
func (r StateRepository) ListAlerts(ctx context.Context, limit int, state string) ([]Alert, error) {
|
||||
if r.Pool == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, errors.New("alert limit is invalid")
|
||||
}
|
||||
if state != "" && !validState(State(state)) {
|
||||
return nil, errors.New("alert state filter is invalid")
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `SELECT i.id,i.rule_id,i.rule_version_id,i.fingerprint,COALESCE(i.entity_id::text,''),i.current_state,i.retained_state,i.active_since,i.recovery_since,i.cooldown_until,i.last_evaluated_at,i.last_known_at,i.last_value,i.reason,i.source_health,COALESCE(i.acknowledged_by,''),i.acknowledged_at,i.revision,i.created_at,i.updated_at,r.name,r.severity,COALESCE(e.entity_type,''),COALESCE(e.display_name,'') FROM alert_instances i JOIN alert_rules r ON r.id=i.rule_id LEFT JOIN entities e ON e.id=i.entity_id WHERE (($2='' AND i.current_state <> 'inactive') OR ($2<>'' AND i.current_state=$2)) ORDER BY CASE i.current_state WHEN 'firing' THEN 1 WHEN 'acknowledged' THEN 2 WHEN 'pending' THEN 3 WHEN 'unknown' THEN 4 WHEN 'resolved' THEN 5 ELSE 6 END,i.updated_at DESC,i.id ASC LIMIT $1`, limit, state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list alerts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]Alert, 0, limit)
|
||||
for rows.Next() {
|
||||
item, err := scanAlert(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate alerts: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r StateRepository) GetAlert(ctx context.Context, id string, occurrenceLimit int) (Alert, error) {
|
||||
if r.Pool == nil {
|
||||
return Alert{}, ErrUnavailable
|
||||
}
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return Alert{}, ErrInstanceNotFound
|
||||
}
|
||||
if occurrenceLimit < 1 || occurrenceLimit > 500 {
|
||||
return Alert{}, errors.New("alert occurrence limit is invalid")
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `SELECT i.id,i.rule_id,i.rule_version_id,i.fingerprint,COALESCE(i.entity_id::text,''),i.current_state,i.retained_state,i.active_since,i.recovery_since,i.cooldown_until,i.last_evaluated_at,i.last_known_at,i.last_value,i.reason,i.source_health,COALESCE(i.acknowledged_by,''),i.acknowledged_at,i.revision,i.created_at,i.updated_at,r.name,r.severity,COALESCE(e.entity_type,''),COALESCE(e.display_name,'') FROM alert_instances i JOIN alert_rules r ON r.id=i.rule_id LEFT JOIN entities e ON e.id=i.entity_id WHERE i.id=$1`, id)
|
||||
item, err := scanAlert(row)
|
||||
if errors.Is(err, ErrInstanceNotFound) || errors.Is(err, pgx.ErrNoRows) {
|
||||
return Alert{}, ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Alert{}, fmt.Errorf("get alert: %w", err)
|
||||
}
|
||||
occurrences, err := r.ListOccurrences(ctx, id, occurrenceLimit)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
item.Occurrences = occurrences
|
||||
return item, nil
|
||||
}
|
||||
|
||||
type alertRow interface{ Scan(...any) error }
|
||||
|
||||
func scanAlert(row alertRow) (Alert, error) {
|
||||
var item Alert
|
||||
var valueJSON, healthJSON []byte
|
||||
var state, retained State
|
||||
err := row.Scan(&item.ID, &item.RuleID, &item.RuleVersionID, &item.Fingerprint, &item.EntityID, &state, &retained, &item.ActiveSince, &item.RecoverySince, &item.CooldownUntil, &item.LastEvaluatedAt, &item.LastKnownAt, &valueJSON, &item.Reason, &healthJSON, &item.AcknowledgedBy, &item.AcknowledgedAt, &item.Revision, &item.CreatedAt, &item.UpdatedAt, &item.RuleName, &item.Severity, &item.EntityType, &item.EntityName)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Alert{}, ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Alert{}, fmt.Errorf("scan alert: %w", err)
|
||||
}
|
||||
item.State, item.RetainedState = state, retained
|
||||
item.LastValue, err = decodeJSON(valueJSON)
|
||||
if err != nil {
|
||||
return Alert{}, fmt.Errorf("decode alert value: %w", err)
|
||||
}
|
||||
item.SourceHealth, err = decodeMap(healthJSON)
|
||||
if err != nil {
|
||||
return Alert{}, fmt.Errorf("decode alert source health: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxFingerprintLabels = 10
|
||||
MaxAlertGroups = 1000
|
||||
MaxSignalsPerGroup = 500
|
||||
)
|
||||
|
||||
var ErrInvalidAlertIdentity = errors.New("invalid alert identity")
|
||||
var alertLabelPattern = regexp.MustCompile("^[A-Za-z0-9_.:/-]+$")
|
||||
|
||||
type Signal struct {
|
||||
InstanceID string
|
||||
RuleID string
|
||||
RuleVersionID string
|
||||
EntityID string
|
||||
Severity string
|
||||
State State
|
||||
Fingerprint string
|
||||
EvaluationKey string
|
||||
ObservedAt time.Time
|
||||
Labels map[string]string
|
||||
GroupBy []string
|
||||
SuppressWhen []string
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
Key string
|
||||
Severity string
|
||||
Labels map[string]string
|
||||
Signals []Signal
|
||||
}
|
||||
|
||||
type Cause struct {
|
||||
Key string
|
||||
State State
|
||||
Confirmed bool
|
||||
Confidence float64
|
||||
ObservedAt time.Time
|
||||
}
|
||||
|
||||
type SuppressionDecision struct {
|
||||
Suppressed bool `json:"suppressed"`
|
||||
CauseKey string `json:"causeKey,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func BuildFingerprint(ruleID, ruleVersionID, entityID string, labels map[string]string) (string, error) {
|
||||
if strings.TrimSpace(ruleID) == "" || strings.TrimSpace(ruleVersionID) == "" || strings.TrimSpace(entityID) == "" || validateLabel(ruleID, 160) != nil || validateLabel(ruleVersionID, 160) != nil || validateLabel(entityID, 160) != nil {
|
||||
return "", ErrInvalidAlertIdentity
|
||||
}
|
||||
canonical, err := canonicalLabels(labels, MaxFingerprintLabels)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := "rule=" + ruleID + "\x00version=" + ruleVersionID + "\x00entity=" + entityID + "\x00" + canonical
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(digest[:]), nil
|
||||
}
|
||||
|
||||
func GroupSignals(signals []Signal) ([]Group, error) {
|
||||
groups := make(map[string]*Group)
|
||||
for _, signal := range signals {
|
||||
if err := validateSignal(signal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, labels, err := groupKey(signal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group := groups[key]
|
||||
if group == nil {
|
||||
if len(groups) >= MaxAlertGroups {
|
||||
return nil, fmt.Errorf("%w: too many alert groups", ErrInvalidAlertIdentity)
|
||||
}
|
||||
group = &Group{Key: key, Severity: signal.Severity, Labels: labels}
|
||||
groups[key] = group
|
||||
}
|
||||
if len(group.Signals) >= MaxSignalsPerGroup {
|
||||
return nil, fmt.Errorf("%w: too many signals in group", ErrInvalidAlertIdentity)
|
||||
}
|
||||
group.Signals = append(group.Signals, signal)
|
||||
}
|
||||
result := make([]Group, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
sort.SliceStable(group.Signals, func(i, j int) bool { return signalSortKey(group.Signals[i]) < signalSortKey(group.Signals[j]) })
|
||||
result = append(result, *group)
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool { return result[i].Key < result[j].Key })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DeduplicateSignals(signals []Signal) ([]Signal, error) {
|
||||
byKey := make(map[string]Signal, len(signals))
|
||||
for _, signal := range signals {
|
||||
if err := validateSignal(signal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := signal.InstanceID + "\x00" + signal.EvaluationKey
|
||||
if previous, exists := byKey[key]; !exists || signalSortKey(signal) > signalSortKey(previous) {
|
||||
byKey[key] = signal
|
||||
}
|
||||
}
|
||||
result := make([]Signal, 0, len(byKey))
|
||||
for _, signal := range byKey {
|
||||
result = append(result, signal)
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool { return signalSortKey(result[i]) < signalSortKey(result[j]) })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EvaluateSuppression(signal Signal, causes []Cause) (SuppressionDecision, error) {
|
||||
if err := validateSignal(signal); err != nil {
|
||||
return SuppressionDecision{}, err
|
||||
}
|
||||
if signal.State != StatePending && signal.State != StateFiring && signal.State != StateAcknowledged && signal.State != StateUnknown {
|
||||
return SuppressionDecision{Reason: "alert_not_active"}, nil
|
||||
}
|
||||
wanted := make(map[string]struct{}, len(signal.SuppressWhen))
|
||||
for _, key := range signal.SuppressWhen {
|
||||
if err := validateLabel(key, 160); err != nil {
|
||||
return SuppressionDecision{}, err
|
||||
}
|
||||
wanted[key] = struct{}{}
|
||||
}
|
||||
ordered := append([]Cause(nil), causes...)
|
||||
sort.SliceStable(ordered, func(i, j int) bool { return causeSortKey(ordered[i]) < causeSortKey(ordered[j]) })
|
||||
for _, cause := range ordered {
|
||||
if _, ok := wanted[cause.Key]; !ok || !causeActive(cause) {
|
||||
continue
|
||||
}
|
||||
if !cause.Confirmed && cause.Confidence < .75 {
|
||||
continue
|
||||
}
|
||||
reason := "dependency_failure"
|
||||
if strings.HasPrefix(cause.Key, "source.") {
|
||||
reason = "source_outage"
|
||||
}
|
||||
return SuppressionDecision{Suppressed: true, CauseKey: cause.Key, Reason: reason}, nil
|
||||
}
|
||||
return SuppressionDecision{Reason: "no_active_suppression_cause"}, nil
|
||||
}
|
||||
|
||||
func groupKey(signal Signal) (string, map[string]string, error) {
|
||||
labels := make(map[string]string, len(signal.GroupBy))
|
||||
for _, key := range signal.GroupBy {
|
||||
if err := validateLabel(key, 80); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if value, ok := signal.Labels[key]; ok {
|
||||
if err := validateLabel(value, 160); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
labels[key] = value
|
||||
}
|
||||
}
|
||||
canonical, err := canonicalLabels(labels, MaxFingerprintLabels)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return signal.RuleID + "|" + signal.Severity + "|" + canonical, labels, nil
|
||||
}
|
||||
|
||||
func validateSignal(signal Signal) error {
|
||||
if signal.InstanceID == "" || signal.RuleID == "" || signal.RuleVersionID == "" || signal.EvaluationKey == "" || signal.Severity == "" || !validState(signal.State) || validateLabel(signal.InstanceID, 160) != nil || validateLabel(signal.RuleID, 160) != nil || validateLabel(signal.RuleVersionID, 160) != nil || validateLabel(signal.EvaluationKey, 160) != nil || validateLabel(signal.Severity, 40) != nil {
|
||||
return ErrInvalidAlertIdentity
|
||||
}
|
||||
if len(signal.GroupBy) > MaxFingerprintLabels || len(signal.Labels) > MaxFingerprintLabels {
|
||||
return fmt.Errorf("%w: label cardinality exceeds limit", ErrInvalidAlertIdentity)
|
||||
}
|
||||
for key, value := range signal.Labels {
|
||||
if err := validateLabel(key, 80); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateLabel(value, 160); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func canonicalLabels(labels map[string]string, max int) (string, error) {
|
||||
if len(labels) > max {
|
||||
return "", fmt.Errorf("%w: too many labels", ErrInvalidAlertIdentity)
|
||||
}
|
||||
keys := make([]string, 0, len(labels))
|
||||
for key, value := range labels {
|
||||
if err := validateLabel(key, 80); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateLabel(value, 160); err != nil {
|
||||
return "", err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+labels[key])
|
||||
}
|
||||
return strings.Join(parts, "\x00"), nil
|
||||
}
|
||||
|
||||
func validateLabel(value string, max int) error {
|
||||
if value == "" || len(value) > max || strings.ContainsAny(value, "\r\n\x00") || !alertLabelPattern.MatchString(value) {
|
||||
return ErrInvalidAlertIdentity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signalSortKey(signal Signal) string {
|
||||
return signal.InstanceID + "|" + signal.EvaluationKey + "|" + signal.ObservedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
func causeSortKey(cause Cause) string {
|
||||
return cause.Key + "|" + string(cause.State) + "|" + cause.ObservedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func causeActive(cause Cause) bool {
|
||||
return cause.State == StateFiring || cause.State == StateAcknowledged || (cause.State == StateUnknown && cause.Confirmed)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func signal(id, rule, evaluation string, state State, labels map[string]string) Signal {
|
||||
return Signal{InstanceID: id, RuleID: rule, RuleVersionID: "version-1", Severity: SeverityDegraded, State: state, EvaluationKey: evaluation, ObservedAt: time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC), Labels: labels, GroupBy: []string{"host", "application"}, SuppressWhen: []string{"host.unreachable", "dns.failure", "source.unavailable"}}
|
||||
}
|
||||
|
||||
func TestBuildFingerprintIsStableAndIncludesRuleBehavior(t *testing.T) {
|
||||
first, err := BuildFingerprint("rule-1", "version-1", "entity-1", map[string]string{"application": "media", "host": "pulse"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := BuildFingerprint("rule-1", "version-1", "entity-1", map[string]string{"host": "pulse", "application": "media"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second || len(first) != 64 {
|
||||
t.Fatalf("fingerprint instability: %q %q", first, second)
|
||||
}
|
||||
changedVersion, err := BuildFingerprint("rule-1", "version-2", "entity-1", map[string]string{"host": "pulse", "application": "media"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changedVersion == first {
|
||||
t.Fatal("rule version did not affect fingerprint")
|
||||
}
|
||||
tooMany := make(map[string]string, MaxFingerprintLabels+1)
|
||||
for i := 0; i <= MaxFingerprintLabels; i++ {
|
||||
tooMany["label"+string(rune('a'+i))] = "value"
|
||||
}
|
||||
if _, err := BuildFingerprint("rule-1", "version-1", "entity-1", tooMany); !errors.Is(err, ErrInvalidAlertIdentity) {
|
||||
t.Fatalf("too many labels error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupAndDeduplicateSignalsAreDeterministic(t *testing.T) {
|
||||
inputs := []Signal{
|
||||
signal("instance-b", "rule-1", "slot-1", StateFiring, map[string]string{"host": "pulse", "application": "media"}),
|
||||
signal("instance-a", "rule-1", "slot-1", StateFiring, map[string]string{"host": "pulse", "application": "media"}),
|
||||
signal("instance-a", "rule-1", "slot-1", StateFiring, map[string]string{"host": "pulse", "application": "media"}),
|
||||
signal("instance-c", "rule-1", "slot-1", StateFiring, map[string]string{"host": "other", "application": "media"}),
|
||||
}
|
||||
deduplicated, err := DeduplicateSignals(inputs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(deduplicated) != 3 {
|
||||
t.Fatalf("deduplicated signals = %d, want 3", len(deduplicated))
|
||||
}
|
||||
groups, err := GroupSignals(deduplicated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(groups) != 2 || len(groups[0].Signals) != 1 || len(groups[1].Signals) != 2 {
|
||||
t.Fatalf("unexpected groups: %#v", groups)
|
||||
}
|
||||
if groups[0].Key > groups[1].Key {
|
||||
t.Fatal("groups are not sorted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressionScenariosRemainInspectablyBounded(t *testing.T) {
|
||||
base := signal("instance-1", "rule-service", "slot-1", StateFiring, map[string]string{"host": "pulse", "application": "web"})
|
||||
tests := []struct {
|
||||
name string
|
||||
cause Cause
|
||||
want bool
|
||||
reason string
|
||||
}{
|
||||
{name: "host outage", cause: Cause{Key: "host.unreachable", State: StateFiring, Confirmed: true}, want: true, reason: "dependency_failure"},
|
||||
{name: "dns outage", cause: Cause{Key: "dns.failure", State: StateAcknowledged, Confidence: .9}, want: true, reason: "dependency_failure"},
|
||||
{name: "source outage", cause: Cause{Key: "source.unavailable", State: StateUnknown, Confirmed: true}, want: true, reason: "source_outage"},
|
||||
{name: "low confidence", cause: Cause{Key: "host.unreachable", State: StateFiring, Confidence: .5}, want: false, reason: "no_active_suppression_cause"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
decision, err := EvaluateSuppression(base, []Cause{test.cause})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.Suppressed != test.want || decision.Reason != test.reason {
|
||||
t.Fatalf("decision = %#v, want suppressed=%v reason=%s", decision, test.want, test.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
resolved := base
|
||||
resolved.State = StateResolved
|
||||
decision, err := EvaluateSuppression(resolved, []Cause{{Key: "host.unreachable", State: StateFiring, Confirmed: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.Suppressed {
|
||||
t.Fatal("resolved alert was suppressed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func Unacknowledge(current Snapshot, actor string, at time.Time) (TransitionResult, error) {
|
||||
current = current.normalized()
|
||||
if actor == "" || len(actor) > 160 || at.IsZero() {
|
||||
return TransitionResult{}, ErrInvalidObservation
|
||||
}
|
||||
if current.State != StateAcknowledged {
|
||||
return TransitionResult{}, ErrStateConflict
|
||||
}
|
||||
result := current
|
||||
result.State = StateFiring
|
||||
result.RetainedState = StateFiring
|
||||
result.AcknowledgedBy = ""
|
||||
result.AcknowledgedAt = nil
|
||||
result.Reason = "unacknowledged"
|
||||
return finish(current, result, StateFiring, "unacknowledge"), nil
|
||||
}
|
||||
|
||||
func (r StateRepository) AcknowledgeRevision(ctx context.Context, instanceID, actor, evaluationKey string, at time.Time, expectedRevision int64) (Instance, Occurrence, bool, error) {
|
||||
return r.applyOperation(ctx, instanceID, actor, evaluationKey, at, expectedRevision, true)
|
||||
}
|
||||
|
||||
func (r StateRepository) Unacknowledge(ctx context.Context, instanceID, actor, evaluationKey string, at time.Time, expectedRevision int64) (Instance, Occurrence, bool, error) {
|
||||
return r.applyOperation(ctx, instanceID, actor, evaluationKey, at, expectedRevision, false)
|
||||
}
|
||||
|
||||
func (r StateRepository) applyOperation(ctx context.Context, instanceID, actor, evaluationKey string, at time.Time, expectedRevision int64, acknowledge bool) (Instance, Occurrence, bool, error) {
|
||||
if r.Pool == nil {
|
||||
return Instance{}, Occurrence{}, false, ErrUnavailable
|
||||
}
|
||||
if instanceID == "" || actor == "" || len(actor) > 160 || evaluationKey == "" || len(evaluationKey) > 160 || at.IsZero() || expectedRevision < 1 {
|
||||
return Instance{}, Occurrence{}, false, ErrInvalidObservation
|
||||
}
|
||||
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, fmt.Errorf("begin alert operation: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var current Instance
|
||||
if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1 FOR UPDATE`, instanceID), ¤t); errors.Is(err, ErrInstanceNotFound) {
|
||||
return Instance{}, Occurrence{}, false, ErrInstanceNotFound
|
||||
} else if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if occurrence, err := scanOccurrence(tx.QueryRow(ctx, `SELECT id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at FROM alert_occurrences WHERE instance_id=$1 AND evaluation_key=$2`, instanceID, evaluationKey)); err == nil {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Instance{}, Occurrence{}, false, fmt.Errorf("commit idempotent alert operation: %w", err)
|
||||
}
|
||||
return current, occurrence, true, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if current.Revision != expectedRevision {
|
||||
return Instance{}, Occurrence{}, false, ErrRevisionConflict
|
||||
}
|
||||
var transition TransitionResult
|
||||
if acknowledge {
|
||||
transition, err = Acknowledge(snapshotFromInstance(current), actor, at.UTC())
|
||||
} else {
|
||||
transition, err = Unacknowledge(snapshotFromInstance(current), actor, at.UTC())
|
||||
}
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
valueJSON, err := boundedJSON(current.LastValue, 128<<10)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
healthJSON, err := boundedJSON(nonNilMap(current.SourceHealth), 64<<10)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
acknowledgedBy := nullableText(transition.Snapshot.AcknowledgedBy)
|
||||
acknowledgedAt := transition.Snapshot.AcknowledgedAt
|
||||
if _, err := tx.Exec(ctx, `UPDATE alert_instances SET current_state=$1,retained_state=$2,reason=$3,acknowledged_by=$4,acknowledged_at=$5,revision=revision+1,updated_at=now() WHERE id=$6 AND revision=$7`, transition.Snapshot.State, transition.Snapshot.RetainedState, transition.Snapshot.Reason, acknowledgedBy, acknowledgedAt, instanceID, expectedRevision); err != nil {
|
||||
return Instance{}, Occurrence{}, false, mapStateError(fmt.Errorf("update alert operation: %w", err))
|
||||
}
|
||||
occurrence, err := insertOccurrence(ctx, tx, instanceID, evaluationKey, transition, Observation{ObservedAt: at.UTC(), Reason: transition.Snapshot.Reason, Value: current.LastValue}, valueJSON, healthJSON)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1`, instanceID), ¤t); err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Instance{}, Occurrence{}, false, fmt.Errorf("commit alert operation: %w", err)
|
||||
}
|
||||
return current, occurrence, false, nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
)
|
||||
|
||||
func TestPostgreSQLAlertOperationsAreRevisionSafeAndRestartable(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document, registry := validDocument(t)
|
||||
document.Enabled = true
|
||||
rules := Repository{Pool: pool, Registry: registry}
|
||||
created, version, err := rules.Create(ctx, "operations-integration", document, "operations test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := StateRepository{Pool: pool}
|
||||
base := time.Date(2026, time.January, 4, 12, 0, 0, 0, time.UTC)
|
||||
policy := Policy{PendingSeconds: 0, ResolveSeconds: 0, UnknownBehavior: UnknownRetain}
|
||||
firing, _, _, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "operations:test", Policy: policy, Observation: observation(base, "evaluation-1", true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firing.State != StateFiring {
|
||||
t.Fatalf("state = %s", firing.State)
|
||||
}
|
||||
ack, occurrence, duplicate, err := store.AcknowledgeRevision(ctx, firing.ID, "operator", "ack-operation-1", base.Add(time.Second), firing.Revision)
|
||||
if err != nil || duplicate || ack.State != StateAcknowledged || occurrence.EventType != "acknowledge" {
|
||||
t.Fatalf("ack result=%#v occurrence=%#v duplicate=%v err=%v", ack, occurrence, duplicate, err)
|
||||
}
|
||||
retry, _, duplicate, err := store.AcknowledgeRevision(ctx, firing.ID, "operator", "ack-operation-1", base.Add(time.Second), firing.Revision)
|
||||
if err != nil || !duplicate || retry.Revision != ack.Revision {
|
||||
t.Fatalf("ack retry=%#v duplicate=%v err=%v", retry, duplicate, err)
|
||||
}
|
||||
restarted := StateRepository{Pool: pool}
|
||||
persisted, err := restarted.GetInstance(ctx, firing.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persisted.State != StateAcknowledged || persisted.AcknowledgedBy != "operator" {
|
||||
t.Fatalf("ack did not survive restart: %#v", persisted)
|
||||
}
|
||||
unack, occurrence, duplicate, err := restarted.Unacknowledge(ctx, firing.ID, "operator", "unack-operation-1", base.Add(2*time.Second), ack.Revision)
|
||||
if err != nil || duplicate || unack.State != StateFiring || unack.AcknowledgedBy != "" || occurrence.EventType != "unacknowledge" {
|
||||
t.Fatalf("unack result=%#v occurrence=%#v duplicate=%v err=%v", unack, occurrence, duplicate, err)
|
||||
}
|
||||
resolved, _, _, err := restarted.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "operations:test", Policy: policy, Observation: observation(base.Add(3*time.Second), "evaluation-2", false)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.State != StateResolved {
|
||||
t.Fatalf("resolved state was lost after unacknowledge: %#v", resolved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUnacknowledgeReturnsFiringAndClearsActor(t *testing.T) {
|
||||
at := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
result, err := Unacknowledge(Snapshot{State: StateAcknowledged, RetainedState: StateAcknowledged, AcknowledgedBy: "operator", AcknowledgedAt: &at, LastValue: 90}, "operator", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.To != StateFiring || result.Snapshot.AcknowledgedBy != "" || result.Snapshot.AcknowledgedAt != nil || result.EventType != "unacknowledge" {
|
||||
t.Fatalf("unexpected unacknowledge result: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnacknowledgeRejectsResolvedAndInactive(t *testing.T) {
|
||||
at := time.Now().UTC()
|
||||
for _, state := range []State{StateInactive, StatePending, StateFiring, StateResolved, StateUnknown} {
|
||||
if _, err := Unacknowledge(Snapshot{State: state}, "operator", at); err != ErrStateConflict {
|
||||
t.Fatalf("state %s error = %v", state, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/itworx/pulse/internal/metriccatalog"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Create(context.Context, string, Document, string) (Rule, Version, error)
|
||||
Get(context.Context, string) (Rule, error)
|
||||
List(context.Context, int) ([]Rule, error)
|
||||
Update(context.Context, string, string, int64, Document, string) (Rule, error)
|
||||
Versions(context.Context, string, int) ([]Version, error)
|
||||
SetEnabled(context.Context, string, int64, bool) (Rule, error)
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
Pool *pgxpool.Pool
|
||||
Registry metriccatalog.Registry
|
||||
}
|
||||
|
||||
func (r Repository) Create(ctx context.Context, actor string, document Document, changeSummary string) (Rule, Version, error) {
|
||||
if r.Pool == nil {
|
||||
return Rule{}, Version{}, ErrUnavailable
|
||||
}
|
||||
if err := document.Validate(r.Registry); err != nil {
|
||||
return Rule{}, Version{}, err
|
||||
}
|
||||
if changeSummary == "" {
|
||||
changeSummary = "initial version"
|
||||
}
|
||||
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Rule{}, Version{}, fmt.Errorf("begin alert rule create: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
docJSON, err := document.MarshalCanonical()
|
||||
if err != nil {
|
||||
return Rule{}, Version{}, fmt.Errorf("marshal alert rule: %w", err)
|
||||
}
|
||||
conditionJSON, _ := json.Marshal(document.Condition)
|
||||
scopeJSON, _ := json.Marshal(nonNilMap(document.Scope))
|
||||
groupJSON, _ := json.Marshal(nonNilStrings(document.GroupBy))
|
||||
suppressJSON, _ := json.Marshal(nonNilStrings(document.SuppressWhen))
|
||||
messageJSON, _ := json.Marshal(document.Message)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO alert_rules (id,schema_version,name,enabled,severity,scope,condition,evaluation_interval_seconds,pending_seconds,resolve_seconds,cooldown_seconds,unknown_behavior,group_by,suppress_when,message,revision,created_by) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8,$9,$10,$11,$12,$13::jsonb,$14::jsonb,$15::jsonb,1,(SELECT id FROM users WHERE external_subject=$16))`, document.ID, document.SchemaVersion, document.Name, document.Enabled, document.Severity, scopeJSON, conditionJSON, document.EvaluationIntervalSeconds, document.PendingSeconds, document.ResolveSeconds, document.CooldownSeconds, document.UnknownBehavior, groupJSON, suppressJSON, messageJSON, actor); err != nil {
|
||||
return Rule{}, Version{}, mapError(fmt.Errorf("create alert rule: %w", err))
|
||||
}
|
||||
versionID := NewID()
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO alert_rule_versions (id,rule_id,version_number,document,change_summary,created_by) VALUES ($1,$2,1,$3::jsonb,$4,(SELECT id FROM users WHERE external_subject=$5))`, versionID, document.ID, docJSON, changeSummary, actor); err != nil {
|
||||
return Rule{}, Version{}, mapError(fmt.Errorf("create alert rule version: %w", err))
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `UPDATE alert_rules SET current_version_id=$1 WHERE id=$2`, versionID, document.ID); err != nil {
|
||||
return Rule{}, Version{}, fmt.Errorf("set current alert rule version: %w", err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Rule{}, Version{}, fmt.Errorf("commit alert rule create: %w", err)
|
||||
}
|
||||
rule, err := r.Get(ctx, document.ID)
|
||||
if err != nil {
|
||||
return Rule{}, Version{}, err
|
||||
}
|
||||
versions, err := r.Versions(ctx, document.ID, 1)
|
||||
if err != nil || len(versions) == 0 {
|
||||
return Rule{}, Version{}, err
|
||||
}
|
||||
return rule, versions[0], nil
|
||||
}
|
||||
|
||||
func (r Repository) Get(ctx context.Context, id string) (Rule, error) {
|
||||
if r.Pool == nil {
|
||||
return Rule{}, ErrUnavailable
|
||||
}
|
||||
var rule Rule
|
||||
var scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON []byte
|
||||
var createdBy *string
|
||||
err := r.Pool.QueryRow(ctx, `SELECT r.id,r.schema_version,r.name,r.enabled,r.severity,r.scope,r.condition,r.evaluation_interval_seconds,r.pending_seconds,r.resolve_seconds,r.cooldown_seconds,r.unknown_behavior,r.group_by,r.suppress_when,r.message,r.revision,v.version_number,COALESCE(u.external_subject,''),r.created_at,r.updated_at FROM alert_rules r JOIN alert_rule_versions v ON v.id=r.current_version_id LEFT JOIN users u ON u.id=r.created_by WHERE r.id=$1`, id).Scan(&rule.ID, &rule.SchemaVersion, &rule.Name, &rule.Enabled, &rule.Severity, &scopeJSON, &conditionJSON, &rule.EvaluationIntervalSeconds, &rule.PendingSeconds, &rule.ResolveSeconds, &rule.CooldownSeconds, &rule.UnknownBehavior, &groupJSON, &suppressJSON, &messageJSON, &rule.Revision, &rule.CurrentVersion, &createdBy, &rule.CreatedAt, &rule.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Rule{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Rule{}, fmt.Errorf("get alert rule: %w", err)
|
||||
}
|
||||
rule.CreatedBy = valueOrEmpty(createdBy)
|
||||
if err := decodeStored(&rule.Document, scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON, rule.CooldownSeconds); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (r Repository) List(ctx context.Context, limit int) ([]Rule, error) {
|
||||
if r.Pool == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, errors.New("alert rule limit is invalid")
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `SELECT r.id,r.schema_version,r.name,r.enabled,r.severity,r.scope,r.condition,r.evaluation_interval_seconds,r.pending_seconds,r.resolve_seconds,r.cooldown_seconds,r.unknown_behavior,r.group_by,r.suppress_when,r.message,r.revision,v.version_number,COALESCE(u.external_subject,''),r.created_at,r.updated_at FROM alert_rules r JOIN alert_rule_versions v ON v.id=r.current_version_id LEFT JOIN users u ON u.id=r.created_by ORDER BY r.name ASC,r.id ASC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list alert rules: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Rule, 0, limit)
|
||||
for rows.Next() {
|
||||
var rule Rule
|
||||
var scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON []byte
|
||||
if err := rows.Scan(&rule.ID, &rule.SchemaVersion, &rule.Name, &rule.Enabled, &rule.Severity, &scopeJSON, &conditionJSON, &rule.EvaluationIntervalSeconds, &rule.PendingSeconds, &rule.ResolveSeconds, &rule.CooldownSeconds, &rule.UnknownBehavior, &groupJSON, &suppressJSON, &messageJSON, &rule.Revision, &rule.CurrentVersion, &rule.CreatedBy, &rule.CreatedAt, &rule.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan alert rule: %w", err)
|
||||
}
|
||||
if err := decodeStored(&rule.Document, scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON, rule.CooldownSeconds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, rule)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r Repository) Update(ctx context.Context, id, actor string, expected int64, document Document, changeSummary string) (Rule, error) {
|
||||
if r.Pool == nil {
|
||||
return Rule{}, ErrUnavailable
|
||||
}
|
||||
document.ID = id
|
||||
if err := document.Validate(r.Registry); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Rule{}, fmt.Errorf("begin alert rule update: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var currentRevision int64
|
||||
var currentVersionID string
|
||||
var currentJSON []byte
|
||||
err = tx.QueryRow(ctx, `SELECT revision,current_version_id FROM alert_rules WHERE id=$1 FOR UPDATE`, id).Scan(¤tRevision, ¤tVersionID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Rule{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Rule{}, fmt.Errorf("lock alert rule: %w", err)
|
||||
}
|
||||
if currentRevision != expected {
|
||||
return Rule{}, ErrConflict
|
||||
}
|
||||
if err = tx.QueryRow(ctx, `SELECT document FROM alert_rule_versions WHERE id=$1`, currentVersionID).Scan(¤tJSON); err != nil {
|
||||
return Rule{}, fmt.Errorf("read current alert rule version: %w", err)
|
||||
}
|
||||
nextJSON, err := document.MarshalCanonical()
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
if sameJSON(currentJSON, nextJSON) {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
var currentVersion int
|
||||
if err := tx.QueryRow(ctx, `SELECT version_number FROM alert_rule_versions WHERE id=$1`, currentVersionID).Scan(¤tVersion); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
if changeSummary == "" {
|
||||
changeSummary = "rule update"
|
||||
}
|
||||
versionID := NewID()
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO alert_rule_versions (id,rule_id,version_number,document,change_summary,created_by) VALUES ($1,$2,$3,$4::jsonb,$5,(SELECT id FROM users WHERE external_subject=$6))`, versionID, id, currentVersion+1, nextJSON, changeSummary, actor); err != nil {
|
||||
return Rule{}, mapError(err)
|
||||
}
|
||||
conditionJSON, _ := json.Marshal(document.Condition)
|
||||
scopeJSON, _ := json.Marshal(nonNilMap(document.Scope))
|
||||
groupJSON, _ := json.Marshal(nonNilStrings(document.GroupBy))
|
||||
suppressJSON, _ := json.Marshal(nonNilStrings(document.SuppressWhen))
|
||||
messageJSON, _ := json.Marshal(document.Message)
|
||||
tag, err := tx.Exec(ctx, `UPDATE alert_rules SET schema_version=$1,name=$2,enabled=$3,severity=$4,scope=$5::jsonb,condition=$6::jsonb,evaluation_interval_seconds=$7,pending_seconds=$8,resolve_seconds=$9,cooldown_seconds=$10,unknown_behavior=$11,group_by=$12::jsonb,suppress_when=$13::jsonb,message=$14::jsonb,current_version_id=$15,revision=revision+1,updated_at=now() WHERE id=$16 AND revision=$17`, document.SchemaVersion, document.Name, document.Enabled, document.Severity, scopeJSON, conditionJSON, document.EvaluationIntervalSeconds, document.PendingSeconds, document.ResolveSeconds, document.CooldownSeconds, document.UnknownBehavior, groupJSON, suppressJSON, messageJSON, versionID, id, expected)
|
||||
if err != nil {
|
||||
return Rule{}, mapError(err)
|
||||
}
|
||||
if tag.RowsAffected() != 1 {
|
||||
return Rule{}, ErrConflict
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return Rule{}, fmt.Errorf("commit alert rule update: %w", err)
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r Repository) Versions(ctx context.Context, id string, limit int) ([]Version, error) {
|
||||
if r.Pool == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, errors.New("version limit is invalid")
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `SELECT v.id,v.rule_id,v.version_number,v.document,v.change_summary,COALESCE(u.external_subject,''),v.created_at FROM alert_rule_versions v LEFT JOIN users u ON u.id=v.created_by WHERE v.rule_id=$1 ORDER BY v.version_number DESC,v.id ASC LIMIT $2`, id, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list alert rule versions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Version, 0, limit)
|
||||
for rows.Next() {
|
||||
var version Version
|
||||
var raw []byte
|
||||
if err := rows.Scan(&version.ID, &version.RuleID, &version.VersionNumber, &raw, &version.ChangeSummary, &version.CreatedBy, &version.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan alert rule version: %w", err)
|
||||
}
|
||||
var document Document
|
||||
if _, err := DecodeDocument(raw, r.Registry); err != nil {
|
||||
return nil, fmt.Errorf("decode stored alert rule version: %w", err)
|
||||
} else {
|
||||
document = mustDecode(raw)
|
||||
}
|
||||
version.Document = document
|
||||
result = append(result, version)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(result) == 0 {
|
||||
var exists bool
|
||||
if err := r.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM alert_rules WHERE id=$1)`, id).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r Repository) SetEnabled(ctx context.Context, id string, expected int64, enabled bool) (Rule, error) {
|
||||
if r.Pool == nil {
|
||||
return Rule{}, ErrUnavailable
|
||||
}
|
||||
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var revision int64
|
||||
var current bool
|
||||
if err := tx.QueryRow(ctx, `SELECT revision,enabled FROM alert_rules WHERE id=$1 FOR UPDATE`, id).Scan(&revision, ¤t); errors.Is(err, pgx.ErrNoRows) {
|
||||
return Rule{}, ErrNotFound
|
||||
} else if err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
if revision != expected {
|
||||
return Rule{}, ErrConflict
|
||||
}
|
||||
if current == enabled {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE alert_rules SET enabled=$1,revision=revision+1,updated_at=now() WHERE id=$2 AND revision=$3`, enabled, id, expected); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Rule{}, err
|
||||
}
|
||||
return r.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (r Repository) ListEnabled(ctx context.Context, limit int) ([]Rule, error) {
|
||||
if r.Pool == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
return nil, errors.New("enabled alert rule limit is invalid")
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `SELECT r.id,r.schema_version,r.name,r.enabled,r.severity,r.scope,r.condition,r.evaluation_interval_seconds,r.pending_seconds,r.resolve_seconds,r.cooldown_seconds,r.unknown_behavior,r.group_by,r.suppress_when,r.message,r.revision,v.version_number,COALESCE(u.external_subject,''),r.created_at,r.updated_at FROM alert_rules r JOIN alert_rule_versions v ON v.id=r.current_version_id LEFT JOIN users u ON u.id=r.created_by WHERE r.enabled=true ORDER BY r.evaluation_interval_seconds ASC,r.name ASC,r.id ASC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled alert rules: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Rule, 0, limit)
|
||||
for rows.Next() {
|
||||
var rule Rule
|
||||
var scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON []byte
|
||||
if err := rows.Scan(&rule.ID, &rule.SchemaVersion, &rule.Name, &rule.Enabled, &rule.Severity, &scopeJSON, &conditionJSON, &rule.EvaluationIntervalSeconds, &rule.PendingSeconds, &rule.ResolveSeconds, &rule.CooldownSeconds, &rule.UnknownBehavior, &groupJSON, &suppressJSON, &messageJSON, &rule.Revision, &rule.CurrentVersion, &rule.CreatedBy, &rule.CreatedAt, &rule.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan enabled alert rule: %w", err)
|
||||
}
|
||||
if err := decodeStored(&rule.Document, scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON, rule.CooldownSeconds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, rule)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func decodeStored(document *Document, scopeJSON, conditionJSON, groupJSON, suppressJSON, messageJSON []byte, cooldownSeconds int) error {
|
||||
if err := json.Unmarshal(scopeJSON, &document.Scope); err != nil {
|
||||
return errors.New("invalid stored alert rule scope")
|
||||
}
|
||||
if err := json.Unmarshal(conditionJSON, &document.Condition); err != nil {
|
||||
return errors.New("invalid stored alert rule condition")
|
||||
}
|
||||
if err := json.Unmarshal(groupJSON, &document.GroupBy); err != nil {
|
||||
return errors.New("invalid stored alert rule groups")
|
||||
}
|
||||
if err := json.Unmarshal(suppressJSON, &document.SuppressWhen); err != nil {
|
||||
return errors.New("invalid stored alert rule suppression")
|
||||
}
|
||||
document.CooldownSeconds = cooldownSeconds
|
||||
if err := json.Unmarshal(messageJSON, &document.Message); err != nil {
|
||||
return errors.New("invalid stored alert rule message")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustDecode(raw []byte) Document {
|
||||
var document Document
|
||||
_ = json.Unmarshal(raw, &document)
|
||||
return document
|
||||
}
|
||||
|
||||
func sameJSON(left, right []byte) bool {
|
||||
var a, b any
|
||||
if json.Unmarshal(left, &a) != nil || json.Unmarshal(right, &b) != nil {
|
||||
return bytes.Equal(bytes.TrimSpace(left), bytes.TrimSpace(right))
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
func nonNilMap(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
func nonNilStrings(value []string) []string {
|
||||
if value == nil {
|
||||
return []string{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
func valueOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func mapError(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
)
|
||||
|
||||
func TestPostgreSQLRuleRepositoryLifecycle(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 4, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
document, registry := validDocument(t)
|
||||
repository := Repository{Pool: pool, Registry: registry}
|
||||
created, version, err := repository.Create(ctx, "integration-editor", document, "integration create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Revision != 1 || created.CurrentVersion != 1 || version.VersionNumber != 1 {
|
||||
t.Fatalf("unexpected create: %#v %#v", created, version)
|
||||
}
|
||||
|
||||
if _, _, err := repository.Create(ctx, "integration-editor", document, "duplicate"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("duplicate create error = %v, want conflict", err)
|
||||
}
|
||||
loaded, err := repository.Get(ctx, document.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Name != document.Name || loaded.Condition.Metric != document.Condition.Metric {
|
||||
t.Fatalf("loaded rule mismatch: %#v", loaded)
|
||||
}
|
||||
|
||||
same, err := repository.Update(ctx, document.ID, "integration-editor", 1, document, "idempotent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if same.Revision != 1 || same.CurrentVersion != 1 {
|
||||
t.Fatalf("idempotent update changed revision: %#v", same)
|
||||
}
|
||||
|
||||
changed := document
|
||||
changed.Name = "CPU aandacht gewijzigd"
|
||||
updated, err := repository.Update(ctx, document.ID, "integration-editor", 1, changed, "change")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Revision != 2 || updated.CurrentVersion != 2 {
|
||||
t.Fatalf("unexpected update: %#v", updated)
|
||||
}
|
||||
if _, err := repository.Update(ctx, document.ID, "integration-editor", 1, changed, "stale"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale update error = %v, want conflict", err)
|
||||
}
|
||||
enabled, err := repository.SetEnabled(ctx, document.ID, 2, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !enabled.Enabled || enabled.Revision != 3 {
|
||||
t.Fatalf("unexpected enable: %#v", enabled)
|
||||
}
|
||||
versions, err := repository.Versions(ctx, document.ID, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versions) != 2 || versions[0].VersionNumber != 2 || versions[1].VersionNumber != 1 {
|
||||
t.Fatalf("unexpected immutable versions: %#v", versions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateInactive State = "inactive"
|
||||
StatePending State = "pending"
|
||||
StateFiring State = "firing"
|
||||
StateAcknowledged State = "acknowledged"
|
||||
StateResolved State = "resolved"
|
||||
StateUnknown State = "unknown"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidObservation = errors.New("invalid alert observation")
|
||||
ErrStaleObservation = errors.New("stale alert observation")
|
||||
ErrInstanceNotFound = errors.New("alert instance not found")
|
||||
ErrStateConflict = errors.New("alert instance state conflict")
|
||||
ErrRevisionConflict = errors.New("alert instance revision conflict")
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
PendingSeconds int
|
||||
ResolveSeconds int
|
||||
CooldownSeconds int
|
||||
UnknownBehavior string
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
State State
|
||||
RetainedState State
|
||||
ActiveSince *time.Time
|
||||
RecoverySince *time.Time
|
||||
CooldownUntil *time.Time
|
||||
LastEvaluatedAt time.Time
|
||||
LastKnownAt *time.Time
|
||||
LastValue any
|
||||
Reason string
|
||||
SourceHealth map[string]any
|
||||
AcknowledgedBy string
|
||||
AcknowledgedAt *time.Time
|
||||
}
|
||||
|
||||
type Notification string
|
||||
|
||||
const (
|
||||
NotificationNone Notification = ""
|
||||
NotificationFiring Notification = "firing"
|
||||
NotificationRecovery Notification = "recovery"
|
||||
NotificationUnknown Notification = "unknown"
|
||||
)
|
||||
|
||||
type TransitionResult struct {
|
||||
Snapshot Snapshot
|
||||
From State
|
||||
To State
|
||||
EventType string
|
||||
Notification Notification
|
||||
}
|
||||
type Observation struct {
|
||||
EvaluationKey string
|
||||
ObservedAt time.Time
|
||||
ConditionTrue bool
|
||||
Unknown bool
|
||||
Value any
|
||||
Reason string
|
||||
SourceHealth map[string]any
|
||||
}
|
||||
|
||||
func (s Snapshot) normalized() Snapshot {
|
||||
if s.State == "" {
|
||||
s.State = StateInactive
|
||||
}
|
||||
if s.RetainedState == "" {
|
||||
s.RetainedState = s.State
|
||||
}
|
||||
if s.SourceHealth == nil {
|
||||
s.SourceHealth = map[string]any{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func Transition(current Snapshot, policy Policy, observation Observation) (TransitionResult, error) {
|
||||
current = current.normalized()
|
||||
if observation.ObservedAt.IsZero() || observation.EvaluationKey == "" || len(observation.EvaluationKey) > 160 {
|
||||
return TransitionResult{}, ErrInvalidObservation
|
||||
}
|
||||
if !validState(current.State) || !validState(current.RetainedState) {
|
||||
return TransitionResult{}, fmt.Errorf("%w: invalid current state", ErrInvalidObservation)
|
||||
}
|
||||
if policy.PendingSeconds < 0 || policy.ResolveSeconds < 0 || policy.CooldownSeconds < 0 || policy.CooldownSeconds > 2592000 || policy.UnknownBehavior == "" {
|
||||
return TransitionResult{}, fmt.Errorf("%w: invalid policy", ErrInvalidObservation)
|
||||
}
|
||||
at := observation.ObservedAt.UTC()
|
||||
if !current.LastEvaluatedAt.IsZero() && at.Before(current.LastEvaluatedAt.UTC()) {
|
||||
return TransitionResult{}, ErrStaleObservation
|
||||
}
|
||||
result := current
|
||||
result.LastEvaluatedAt = at
|
||||
result.Reason = boundedReason(observation.Reason)
|
||||
result.SourceHealth = cloneMap(observation.SourceHealth)
|
||||
if observation.Unknown {
|
||||
if policy.UnknownBehavior == UnknownIgnoreGap {
|
||||
result.Reason = "unknown_input_ignored_short_gap"
|
||||
return finish(current, result, State(current.State), "evaluation"), nil
|
||||
}
|
||||
result.RetainedState = current.State
|
||||
if current.State == StateUnknown && current.RetainedState != StateUnknown {
|
||||
result.RetainedState = current.RetainedState
|
||||
}
|
||||
result.State = StateUnknown
|
||||
transition := finish(current, result, StateUnknown, "transition")
|
||||
transition.Notification = notificationFor(current, transition, at)
|
||||
return transition, nil
|
||||
}
|
||||
|
||||
base := current.State
|
||||
if base == StateUnknown {
|
||||
base = current.RetainedState
|
||||
if !validState(base) || base == StateUnknown {
|
||||
base = StateInactive
|
||||
}
|
||||
result.State = base
|
||||
}
|
||||
result.RetainedState = base
|
||||
result.LastKnownAt = timePtr(at)
|
||||
result.LastValue = observation.Value
|
||||
if observation.ConditionTrue {
|
||||
result.RecoverySince = nil
|
||||
switch base {
|
||||
case StateInactive, StateResolved:
|
||||
result.ActiveSince = timePtr(at)
|
||||
if policy.PendingSeconds == 0 {
|
||||
result.State = StateFiring
|
||||
} else {
|
||||
result.State = StatePending
|
||||
}
|
||||
case StatePending:
|
||||
if result.ActiveSince == nil {
|
||||
result.ActiveSince = timePtr(at)
|
||||
}
|
||||
if at.Sub(result.ActiveSince.UTC()) >= time.Duration(policy.PendingSeconds)*time.Second {
|
||||
result.State = StateFiring
|
||||
}
|
||||
case StateFiring, StateAcknowledged:
|
||||
result.State = base
|
||||
default:
|
||||
result.State = StateInactive
|
||||
}
|
||||
} else {
|
||||
result.ActiveSince = current.ActiveSince
|
||||
switch base {
|
||||
case StatePending:
|
||||
result.State = StateInactive
|
||||
result.ActiveSince = nil
|
||||
case StateFiring, StateAcknowledged:
|
||||
if result.RecoverySince == nil {
|
||||
result.RecoverySince = timePtr(at)
|
||||
}
|
||||
if at.Sub(result.RecoverySince.UTC()) >= time.Duration(policy.ResolveSeconds)*time.Second {
|
||||
result.State = StateResolved
|
||||
result.CooldownUntil = timePtr(at.Add(time.Duration(policy.CooldownSeconds) * time.Second))
|
||||
result.ActiveSince = nil
|
||||
result.RecoverySince = nil
|
||||
}
|
||||
default:
|
||||
result.State = StateInactive
|
||||
result.ActiveSince = nil
|
||||
result.RecoverySince = nil
|
||||
}
|
||||
}
|
||||
transition := finish(current, result, result.State, stateEvent(current.State, result.State))
|
||||
transition.Notification = notificationFor(current, transition, at)
|
||||
return transition, nil
|
||||
}
|
||||
|
||||
func Acknowledge(current Snapshot, actor string, at time.Time) (TransitionResult, error) {
|
||||
current = current.normalized()
|
||||
if actor == "" || len(actor) > 160 || at.IsZero() {
|
||||
return TransitionResult{}, ErrInvalidObservation
|
||||
}
|
||||
if current.State != StateFiring && current.State != StatePending {
|
||||
return TransitionResult{}, ErrStateConflict
|
||||
}
|
||||
result := current
|
||||
result.State = StateAcknowledged
|
||||
result.RetainedState = StateAcknowledged
|
||||
result.AcknowledgedBy = actor
|
||||
result.AcknowledgedAt = timePtr(at.UTC())
|
||||
result.Reason = "acknowledged"
|
||||
return finish(current, result, StateAcknowledged, "acknowledge"), nil
|
||||
}
|
||||
|
||||
func finish(current, result Snapshot, state State, eventType string) TransitionResult {
|
||||
result.State = state
|
||||
if result.SourceHealth == nil {
|
||||
result.SourceHealth = map[string]any{}
|
||||
}
|
||||
return TransitionResult{Snapshot: result, From: current.State, To: state, EventType: eventType}
|
||||
}
|
||||
|
||||
func notificationFor(current Snapshot, result TransitionResult, at time.Time) Notification {
|
||||
switch {
|
||||
case result.To == StateFiring && current.State != StateFiring && current.State != StateAcknowledged:
|
||||
if current.CooldownUntil != nil && at.Before(current.CooldownUntil.UTC()) {
|
||||
return NotificationNone
|
||||
}
|
||||
return NotificationFiring
|
||||
case result.To == StateResolved && (current.State == StateFiring || current.State == StateAcknowledged):
|
||||
return NotificationRecovery
|
||||
case result.To == StateUnknown && current.State != StateUnknown:
|
||||
return NotificationUnknown
|
||||
default:
|
||||
return NotificationNone
|
||||
}
|
||||
}
|
||||
|
||||
func stateEvent(from, to State) string {
|
||||
if from == to {
|
||||
return "evaluation"
|
||||
}
|
||||
return "transition"
|
||||
}
|
||||
|
||||
func validState(state State) bool {
|
||||
switch state {
|
||||
case StateInactive, StatePending, StateFiring, StateAcknowledged, StateResolved, StateUnknown:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func boundedReason(reason string) string {
|
||||
if len(reason) > 500 {
|
||||
return reason[:500]
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
func cloneMap(source map[string]any) map[string]any {
|
||||
if source == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
copy := make(map[string]any, len(source))
|
||||
for key, value := range source {
|
||||
copy[key] = value
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
func timePtr(value time.Time) *time.Time {
|
||||
value = value.UTC()
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type StateInput struct {
|
||||
RuleID string
|
||||
RuleVersionID string
|
||||
Fingerprint string
|
||||
EntityID string
|
||||
Policy Policy
|
||||
Observation Observation
|
||||
}
|
||||
|
||||
type Instance struct {
|
||||
ID string `json:"id"`
|
||||
RuleID string `json:"ruleId"`
|
||||
RuleVersionID string `json:"ruleVersionId"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
EntityID string `json:"entityId,omitempty"`
|
||||
State State `json:"state"`
|
||||
RetainedState State `json:"retainedState"`
|
||||
ActiveSince *time.Time `json:"activeSince,omitempty"`
|
||||
RecoverySince *time.Time `json:"recoverySince,omitempty"`
|
||||
LastEvaluatedAt time.Time `json:"lastEvaluatedAt"`
|
||||
LastKnownAt *time.Time `json:"lastKnownAt,omitempty"`
|
||||
LastValue any `json:"lastValue,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
SourceHealth map[string]any `json:"sourceHealth"`
|
||||
AcknowledgedBy string `json:"acknowledgedBy,omitempty"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"`
|
||||
CooldownUntil *time.Time `json:"cooldownUntil,omitempty"`
|
||||
Revision int64 `json:"revision"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Occurrence struct {
|
||||
ID string `json:"id"`
|
||||
InstanceID string `json:"instanceId"`
|
||||
EvaluationKey string `json:"evaluationKey"`
|
||||
EventType string `json:"eventType"`
|
||||
From State `json:"from"`
|
||||
To State `json:"to"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Value any `json:"value,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
SourceHealth map[string]any `json:"sourceHealth"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type StateStore interface {
|
||||
ApplyObservation(context.Context, StateInput) (Instance, Occurrence, bool, error)
|
||||
GetInstance(context.Context, string) (Instance, error)
|
||||
ListOccurrences(context.Context, string, int) ([]Occurrence, error)
|
||||
Acknowledge(context.Context, string, string, string, time.Time) (Instance, Occurrence, bool, error)
|
||||
}
|
||||
|
||||
type StateRepository struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (r StateRepository) ApplyObservation(ctx context.Context, input StateInput) (Instance, Occurrence, bool, error) {
|
||||
if r.Pool == nil {
|
||||
return Instance{}, Occurrence{}, false, ErrUnavailable
|
||||
}
|
||||
if err := validateStateInput(input); err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
healthJSON, err := boundedJSON(nonNilMap(input.Observation.SourceHealth), 64<<10)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, fmt.Errorf("begin alert state transition: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
instanceID := NewID()
|
||||
if err := tx.QueryRow(ctx, `INSERT INTO alert_instances (id,rule_id,rule_version_id,fingerprint,entity_id,last_evaluated_at) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (rule_id,fingerprint) DO NOTHING RETURNING id`, instanceID, input.RuleID, input.RuleVersionID, input.Fingerprint, nullableID(input.EntityID), input.Observation.ObservedAt.UTC()).Scan(&instanceID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Instance{}, Occurrence{}, false, mapStateError(fmt.Errorf("create alert instance: %w", err))
|
||||
}
|
||||
var current Instance
|
||||
if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE rule_id=$1 AND fingerprint=$2 FOR UPDATE`, input.RuleID, input.Fingerprint), ¤t); err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if current.EntityID != input.EntityID {
|
||||
return Instance{}, Occurrence{}, false, ErrStateConflict
|
||||
}
|
||||
if occurrence, err := scanOccurrence(tx.QueryRow(ctx, `SELECT id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at FROM alert_occurrences WHERE instance_id=$1 AND evaluation_key=$2`, current.ID, input.Observation.EvaluationKey)); err == nil {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Instance{}, Occurrence{}, false, fmt.Errorf("commit idempotent alert evaluation: %w", err)
|
||||
}
|
||||
return current, occurrence, true, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
transition, err := Transition(snapshotFromInstance(current), input.Policy, input.Observation)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
resultValue := transition.Snapshot.LastValue
|
||||
if input.Observation.Unknown || input.Policy.UnknownBehavior == UnknownIgnoreGap && transition.Snapshot.LastKnownAt == nil {
|
||||
resultValue = current.LastValue
|
||||
}
|
||||
resultValueJSON, err := boundedJSON(resultValue, 128<<10)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE alert_instances SET rule_version_id=$1,current_state=$2,retained_state=$3,active_since=$4,recovery_since=$5,cooldown_until=$6,last_evaluated_at=$7,last_known_at=$8,last_value=$9::jsonb,reason=$10,source_health=$11::jsonb,acknowledged_by=$12,acknowledged_at=$13,revision=revision+1,updated_at=now() WHERE id=$14`, input.RuleVersionID, transition.Snapshot.State, transition.Snapshot.RetainedState, transition.Snapshot.ActiveSince, transition.Snapshot.RecoverySince, transition.Snapshot.CooldownUntil, transition.Snapshot.LastEvaluatedAt.UTC(), transition.Snapshot.LastKnownAt, resultValueJSON, transition.Snapshot.Reason, healthJSON, nullableText(transition.Snapshot.AcknowledgedBy), transition.Snapshot.AcknowledgedAt, current.ID); err != nil {
|
||||
return Instance{}, Occurrence{}, false, fmt.Errorf("update alert instance: %w", err)
|
||||
}
|
||||
occurrence, err := insertOccurrence(ctx, tx, current.ID, input.Observation.EvaluationKey, transition, input.Observation, resultValueJSON, healthJSON)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1`, current.ID), ¤t); err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Instance{}, Occurrence{}, false, fmt.Errorf("commit alert state transition: %w", err)
|
||||
}
|
||||
return current, occurrence, false, nil
|
||||
}
|
||||
|
||||
func (r StateRepository) GetInstance(ctx context.Context, id string) (Instance, error) {
|
||||
if r.Pool == nil {
|
||||
return Instance{}, ErrUnavailable
|
||||
}
|
||||
var instance Instance
|
||||
err := scanInstance(r.Pool.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1`, id), &instance)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Instance{}, ErrInstanceNotFound
|
||||
}
|
||||
return instance, err
|
||||
}
|
||||
|
||||
func (r StateRepository) ListOccurrences(ctx context.Context, instanceID string, limit int) ([]Occurrence, error) {
|
||||
if r.Pool == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if limit < 1 || limit > 500 {
|
||||
return nil, errors.New("alert occurrence limit is invalid")
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `SELECT id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at FROM alert_occurrences WHERE instance_id=$1 ORDER BY observed_at DESC,id ASC LIMIT $2`, instanceID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list alert occurrences: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Occurrence, 0, limit)
|
||||
for rows.Next() {
|
||||
occurrence, err := scanOccurrence(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, occurrence)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(result) == 0 {
|
||||
if _, err := r.GetInstance(ctx, instanceID); errors.Is(err, ErrInstanceNotFound) {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r StateRepository) Acknowledge(ctx context.Context, instanceID, actor, evaluationKey string, at time.Time) (Instance, Occurrence, bool, error) {
|
||||
if r.Pool == nil {
|
||||
return Instance{}, Occurrence{}, false, ErrUnavailable
|
||||
}
|
||||
if instanceID == "" || actor == "" || len(actor) > 160 || evaluationKey == "" || len(evaluationKey) > 160 || at.IsZero() {
|
||||
return Instance{}, Occurrence{}, false, ErrInvalidObservation
|
||||
}
|
||||
tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, fmt.Errorf("begin alert acknowledgement: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var current Instance
|
||||
if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1 FOR UPDATE`, instanceID), ¤t); errors.Is(err, pgx.ErrNoRows) {
|
||||
return Instance{}, Occurrence{}, false, ErrInstanceNotFound
|
||||
} else if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if occurrence, err := scanOccurrence(tx.QueryRow(ctx, `SELECT id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at FROM alert_occurrences WHERE instance_id=$1 AND evaluation_key=$2`, instanceID, evaluationKey)); err == nil {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
return current, occurrence, true, nil
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
transition, err := Acknowledge(snapshotFromInstance(current), actor, at.UTC())
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
valueJSON, err := boundedJSON(current.LastValue, 128<<10)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
healthJSON, err := boundedJSON(nonNilMap(current.SourceHealth), 64<<10)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE alert_instances SET current_state=$1,retained_state=$2,reason=$3,acknowledged_by=$4,acknowledged_at=$5,revision=revision+1,updated_at=now() WHERE id=$6`, transition.Snapshot.State, transition.Snapshot.RetainedState, transition.Snapshot.Reason, actor, transition.Snapshot.AcknowledgedAt, instanceID); err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
occurrence, err := insertOccurrence(ctx, tx, instanceID, evaluationKey, transition, Observation{ObservedAt: at.UTC(), Reason: "acknowledged", Value: current.LastValue}, valueJSON, healthJSON)
|
||||
if err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if err := scanInstance(tx.QueryRow(ctx, `SELECT id,rule_id,rule_version_id,fingerprint,COALESCE(entity_id::text,''),current_state,retained_state,active_since,recovery_since,cooldown_until,last_evaluated_at,last_known_at,last_value,reason,source_health,COALESCE(acknowledged_by,''),acknowledged_at,revision,created_at,updated_at FROM alert_instances WHERE id=$1`, instanceID), ¤t); err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Instance{}, Occurrence{}, false, err
|
||||
}
|
||||
return current, occurrence, false, nil
|
||||
}
|
||||
|
||||
func scanInstance(row pgx.Row, instance *Instance) error {
|
||||
var valueJSON, healthJSON []byte
|
||||
err := row.Scan(&instance.ID, &instance.RuleID, &instance.RuleVersionID, &instance.Fingerprint, &instance.EntityID, &instance.State, &instance.RetainedState, &instance.ActiveSince, &instance.RecoverySince, &instance.CooldownUntil, &instance.LastEvaluatedAt, &instance.LastKnownAt, &valueJSON, &instance.Reason, &healthJSON, &instance.AcknowledgedBy, &instance.AcknowledgedAt, &instance.Revision, &instance.CreatedAt, &instance.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan alert instance: %w", err)
|
||||
}
|
||||
instance.LastValue, err = decodeJSON(valueJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode alert instance value: %w", err)
|
||||
}
|
||||
instance.SourceHealth, err = decodeMap(healthJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode alert instance source health: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanOccurrence(row interface{ Scan(...any) error }) (Occurrence, error) {
|
||||
var occurrence Occurrence
|
||||
var valueJSON, healthJSON []byte
|
||||
err := row.Scan(&occurrence.ID, &occurrence.InstanceID, &occurrence.EvaluationKey, &occurrence.EventType, &occurrence.From, &occurrence.To, &occurrence.ObservedAt, &valueJSON, &occurrence.Reason, &healthJSON, &occurrence.CreatedAt)
|
||||
if err != nil {
|
||||
return Occurrence{}, err
|
||||
}
|
||||
occurrence.Value, err = decodeJSON(valueJSON)
|
||||
if err != nil {
|
||||
return Occurrence{}, fmt.Errorf("decode alert occurrence value: %w", err)
|
||||
}
|
||||
occurrence.SourceHealth, err = decodeMap(healthJSON)
|
||||
if err != nil {
|
||||
return Occurrence{}, fmt.Errorf("decode alert occurrence source health: %w", err)
|
||||
}
|
||||
return occurrence, nil
|
||||
}
|
||||
|
||||
func insertOccurrence(ctx context.Context, tx pgx.Tx, instanceID, key string, transition TransitionResult, observation Observation, valueJSON, healthJSON []byte) (Occurrence, error) {
|
||||
var occurrence Occurrence
|
||||
err := tx.QueryRow(ctx, `INSERT INTO alert_occurrences (id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10::jsonb) RETURNING id,instance_id,evaluation_key,event_type,from_state,to_state,observed_at,value,reason,source_health,created_at`, NewID(), instanceID, key, transition.EventType, transition.From, transition.To, observation.ObservedAt.UTC(), valueJSON, transition.Snapshot.Reason, healthJSON).Scan(&occurrence.ID, &occurrence.InstanceID, &occurrence.EvaluationKey, &occurrence.EventType, &occurrence.From, &occurrence.To, &occurrence.ObservedAt, &valueJSON, &occurrence.Reason, &healthJSON, &occurrence.CreatedAt)
|
||||
if err != nil {
|
||||
return Occurrence{}, mapStateError(fmt.Errorf("insert alert occurrence: %w", err))
|
||||
}
|
||||
occurrence.Value, err = decodeJSON(valueJSON)
|
||||
if err != nil {
|
||||
return Occurrence{}, err
|
||||
}
|
||||
occurrence.SourceHealth, err = decodeMap(healthJSON)
|
||||
if err != nil {
|
||||
return Occurrence{}, err
|
||||
}
|
||||
return occurrence, nil
|
||||
}
|
||||
|
||||
func snapshotFromInstance(instance Instance) Snapshot {
|
||||
return Snapshot{State: instance.State, RetainedState: instance.RetainedState, ActiveSince: instance.ActiveSince, RecoverySince: instance.RecoverySince, CooldownUntil: instance.CooldownUntil, LastEvaluatedAt: instance.LastEvaluatedAt, LastKnownAt: instance.LastKnownAt, LastValue: instance.LastValue, Reason: instance.Reason, SourceHealth: instance.SourceHealth, AcknowledgedBy: instance.AcknowledgedBy, AcknowledgedAt: instance.AcknowledgedAt}
|
||||
}
|
||||
|
||||
func validateStateInput(input StateInput) error {
|
||||
if strings.TrimSpace(input.RuleID) == "" || strings.TrimSpace(input.RuleVersionID) == "" || input.Fingerprint == "" || len(input.Fingerprint) > 160 {
|
||||
return ErrInvalidObservation
|
||||
}
|
||||
if input.Policy.UnknownBehavior != UnknownRetain && input.Policy.UnknownBehavior != UnknownBecome && input.Policy.UnknownBehavior != UnknownIgnoreGap {
|
||||
return ErrInvalidObservation
|
||||
}
|
||||
if input.Policy.PendingSeconds < 0 || input.Policy.ResolveSeconds < 0 {
|
||||
return ErrInvalidObservation
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boundedJSON(value any, max int) ([]byte, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode alert state JSON: %w", err)
|
||||
}
|
||||
if len(encoded) > max {
|
||||
return nil, ErrInvalidObservation
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func decodeJSON(raw []byte) (any, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func decodeMap(raw []byte) (map[string]any, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func nullableID(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func nullableText(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func mapStateError(err error) error {
|
||||
var pgErr interface{ SQLState() string }
|
||||
if errors.As(err, &pgErr) && pgErr.SQLState() == "23505" {
|
||||
return ErrStateConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
)
|
||||
|
||||
func TestPostgreSQLAlertStateLifecycleAndIdempotence(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document, registry := validDocument(t)
|
||||
document.Enabled = true
|
||||
rules := Repository{Pool: pool, Registry: registry}
|
||||
created, version, err := rules.Create(ctx, "state-integration", document, "state test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := StateRepository{Pool: pool}
|
||||
policy := Policy{PendingSeconds: document.PendingSeconds, ResolveSeconds: document.ResolveSeconds, UnknownBehavior: document.UnknownBehavior}
|
||||
base := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
|
||||
first, firstOccurrence, duplicate, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: observation(base, "slot-1", true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if duplicate || first.State != StatePending || firstOccurrence.To != StatePending {
|
||||
t.Fatalf("unexpected first state: %#v %#v", first, firstOccurrence)
|
||||
}
|
||||
replayed, replayOccurrence, duplicate, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: observation(base, "slot-1", true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !duplicate || replayed.Revision != first.Revision || replayOccurrence.ID != firstOccurrence.ID {
|
||||
t.Fatalf("replay was not idempotent: %#v %#v", replayed, replayOccurrence)
|
||||
}
|
||||
firing, _, _, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: observation(base.Add(60*time.Second), "slot-2", true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firing.State != StateFiring {
|
||||
t.Fatalf("pending did not fire: %#v", firing)
|
||||
}
|
||||
acknowledged, _, _, err := store.Acknowledge(ctx, firing.ID, "operator", "ack-1", base.Add(61*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acknowledged.State != StateAcknowledged {
|
||||
t.Fatalf("acknowledgement failed: %#v", acknowledged)
|
||||
}
|
||||
stillFiring, _, _, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: observation(base.Add(62*time.Second), "slot-3", true)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stillFiring.State != StateAcknowledged {
|
||||
t.Fatalf("acknowledged firing alert changed state: %#v", stillFiring)
|
||||
}
|
||||
unknownObservation := observation(base.Add(90*time.Second), "slot-4", false)
|
||||
unknownObservation.Unknown = true
|
||||
unknown, _, _, err := store.ApplyObservation(ctx, StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:test", Policy: policy, Observation: unknownObservation})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unknown.State != StateUnknown || unknown.RetainedState != StateAcknowledged {
|
||||
t.Fatalf("unknown state lost acknowledgement context: %#v", unknown)
|
||||
}
|
||||
restarted := StateRepository{Pool: pool}
|
||||
loaded, err := restarted.GetInstance(ctx, unknown.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.State != StateUnknown || loaded.LastKnownAt == nil || loaded.LastValue == nil {
|
||||
t.Fatalf("restart did not preserve state: %#v", loaded)
|
||||
}
|
||||
occurrences, err := restarted.ListOccurrences(ctx, unknown.ID, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(occurrences) != 5 {
|
||||
t.Fatalf("occurrence count = %d, want 5", len(occurrences))
|
||||
}
|
||||
|
||||
missingVersion := StateInput{RuleID: created.ID, RuleVersionID: NewID(), Fingerprint: "rollback", Policy: policy, Observation: observation(base, "rollback", true)}
|
||||
if _, _, _, err := store.ApplyObservation(ctx, missingVersion); err == nil {
|
||||
t.Fatal("missing foreign key did not fail")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `SELECT 1 FROM alert_instances WHERE rule_id=$1 AND fingerprint=$2`, created.ID, "rollback"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgreSQLAlertStateCoordinatesOverlappingWrites(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document, registry := validDocument(t)
|
||||
rules := Repository{Pool: pool, Registry: registry}
|
||||
created, version, err := rules.Create(ctx, "state-concurrency", document, "state concurrency")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := StateRepository{Pool: pool}
|
||||
input := StateInput{RuleID: created.ID, RuleVersionID: version.ID, Fingerprint: "host:concurrent", Policy: Policy{PendingSeconds: 0, ResolveSeconds: 0, UnknownBehavior: UnknownRetain}, Observation: observation(time.Date(2026, time.January, 3, 12, 0, 0, 0, time.UTC), "same-slot", true)}
|
||||
const workers = 8
|
||||
results := make(chan bool, workers)
|
||||
errorsCh := make(chan error, workers)
|
||||
var group sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
_, _, duplicate, err := store.ApplyObservation(ctx, input)
|
||||
if err != nil {
|
||||
errorsCh <- err
|
||||
return
|
||||
}
|
||||
results <- duplicate
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(results)
|
||||
close(errorsCh)
|
||||
for err := range errorsCh {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createdCount := 0
|
||||
for duplicate := range results {
|
||||
if !duplicate {
|
||||
createdCount++
|
||||
}
|
||||
}
|
||||
if createdCount != 1 {
|
||||
t.Fatalf("non-idempotent concurrent writes = %d, want 1", createdCount)
|
||||
}
|
||||
var occurrenceCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM alert_occurrences WHERE instance_id=(SELECT id FROM alert_instances WHERE rule_id=$1 AND fingerprint=$2)`, created.ID, input.Fingerprint).Scan(&occurrenceCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if occurrenceCount != 1 {
|
||||
t.Fatalf("occurrences = %d, want 1", occurrenceCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testPolicy() Policy {
|
||||
return Policy{PendingSeconds: 60, ResolveSeconds: 30, UnknownBehavior: UnknownRetain}
|
||||
}
|
||||
|
||||
func observation(at time.Time, key string, fire bool) Observation {
|
||||
return Observation{EvaluationKey: key, ObservedAt: at, ConditionTrue: fire, Value: 90, Reason: "condition_evaluated", SourceHealth: map[string]any{"source": "test"}}
|
||||
}
|
||||
|
||||
func TestStateTransitionTable(t *testing.T) {
|
||||
start := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
state State
|
||||
at time.Time
|
||||
fire bool
|
||||
want State
|
||||
}{
|
||||
{name: "inactive enters pending", state: StateInactive, at: start, fire: true, want: StatePending},
|
||||
{name: "pending remains pending", state: StatePending, at: start.Add(30 * time.Second), fire: true, want: StatePending},
|
||||
{name: "pending fires after duration", state: StatePending, at: start.Add(60 * time.Second), fire: true, want: StateFiring},
|
||||
{name: "pending clears", state: StatePending, at: start.Add(30 * time.Second), fire: false, want: StateInactive},
|
||||
{name: "firing starts recovery", state: StateFiring, at: start, fire: false, want: StateFiring},
|
||||
{name: "firing resolves after duration", state: StateFiring, at: start.Add(30 * time.Second), fire: false, want: StateResolved},
|
||||
{name: "resolved reopens", state: StateResolved, at: start, fire: true, want: StatePending},
|
||||
{name: "acknowledged remains firing", state: StateAcknowledged, at: start, fire: true, want: StateAcknowledged},
|
||||
{name: "acknowledged starts recovery", state: StateAcknowledged, at: start, fire: false, want: StateAcknowledged},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := Snapshot{State: test.state, RetainedState: test.state}
|
||||
if test.state == StatePending {
|
||||
current.ActiveSince = timePtr(start)
|
||||
}
|
||||
if test.state == StateFiring || test.state == StateAcknowledged {
|
||||
current.RecoverySince = timePtr(start)
|
||||
}
|
||||
result, err := Transition(current, testPolicy(), observation(test.at, "slot-"+test.name, test.fire))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.To != test.want {
|
||||
t.Fatalf("state = %s, want %s", result.To, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcknowledgementIsExplicitAndPreservesFiringContext(t *testing.T) {
|
||||
at := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
|
||||
result, err := Acknowledge(Snapshot{State: StateFiring, RetainedState: StateFiring, LastValue: 92}, "operator", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.To != StateAcknowledged || result.Snapshot.LastValue != 92 || result.Snapshot.AcknowledgedBy != "operator" {
|
||||
t.Fatalf("unexpected acknowledgement: %#v", result)
|
||||
}
|
||||
later, err := Transition(result.Snapshot, testPolicy(), observation(at.Add(time.Second), "slot-ack", true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if later.To != StateAcknowledged {
|
||||
t.Fatalf("acknowledged alert did not remain acknowledged while firing: %#v", later)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownDoesNotFalseResolveAndRecoveryUsesRetainedState(t *testing.T) {
|
||||
at := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
|
||||
unknown, err := Transition(Snapshot{State: StateFiring, RetainedState: StateFiring, LastKnownAt: timePtr(at), LastValue: 91}, testPolicy(), Observation{EvaluationKey: "unknown", ObservedAt: at.Add(time.Minute), Unknown: true, Reason: "source_stale", SourceHealth: map[string]any{"state": "unknown"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unknown.To != StateUnknown || unknown.Snapshot.RetainedState != StateFiring || unknown.Snapshot.LastValue != 91 {
|
||||
t.Fatalf("unknown transition lost firing context: %#v", unknown)
|
||||
}
|
||||
recovered, err := Transition(unknown.Snapshot, testPolicy(), observation(at.Add(2*time.Minute), "recovery", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recovered.To != StateFiring {
|
||||
t.Fatalf("unknown recovery falsely resolved immediately: %#v", recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRejectsOutOfOrderObservation(t *testing.T) {
|
||||
at := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
|
||||
_, err := Transition(Snapshot{State: StateFiring, RetainedState: StateFiring, LastEvaluatedAt: at}, testPolicy(), observation(at.Add(-time.Second), "old", false))
|
||||
if !errors.Is(err, ErrStaleObservation) {
|
||||
t.Fatalf("error = %v, want ErrStaleObservation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreUnknownGapPreservesState(t *testing.T) {
|
||||
at := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
|
||||
policy := testPolicy()
|
||||
policy.UnknownBehavior = UnknownIgnoreGap
|
||||
result, err := Transition(Snapshot{State: StateFiring, RetainedState: StateFiring, LastEvaluatedAt: at, LastValue: 80}, policy, Observation{EvaluationKey: "gap", ObservedAt: at.Add(time.Second), Unknown: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.To != StateFiring || result.Snapshot.LastValue != 80 {
|
||||
t.Fatalf("unknown gap changed state: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskTemperatureScenarioUsesPendingRecoveryAndCooldownDeterministically(t *testing.T) {
|
||||
start := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
|
||||
policy := Policy{PendingSeconds: 300, ResolveSeconds: 300, UnknownBehavior: UnknownRetain}
|
||||
current := Snapshot{State: StateInactive, RetainedState: StateInactive}
|
||||
transition, err := Transition(current, policy, observation(start, "disk-1", true))
|
||||
if err != nil || transition.To != StatePending {
|
||||
t.Fatalf("initial transition = %#v, %v", transition, err)
|
||||
}
|
||||
current = transition.Snapshot
|
||||
transition, err = Transition(current, policy, observation(start.Add(299*time.Second), "disk-2", true))
|
||||
if err != nil || transition.To != StatePending {
|
||||
t.Fatalf("boundary pending transition = %#v, %v", transition, err)
|
||||
}
|
||||
current = transition.Snapshot
|
||||
transition, err = Transition(current, policy, observation(start.Add(300*time.Second), "disk-3", true))
|
||||
if err != nil || transition.To != StateFiring {
|
||||
t.Fatalf("fire transition = %#v, %v", transition, err)
|
||||
}
|
||||
current = transition.Snapshot
|
||||
transition, err = Transition(current, policy, observation(start.Add(330*time.Second), "disk-4", true))
|
||||
if err != nil || transition.To != StateFiring {
|
||||
t.Fatalf("temperature at recovery threshold changed state: %#v, %v", transition, err)
|
||||
}
|
||||
current = transition.Snapshot
|
||||
transition, err = Transition(current, policy, observation(start.Add(360*time.Second), "disk-5", false))
|
||||
if err != nil || transition.To != StateFiring {
|
||||
t.Fatalf("recovery start transition = %#v, %v", transition, err)
|
||||
}
|
||||
current = transition.Snapshot
|
||||
transition, err = Transition(current, policy, observation(start.Add(659*time.Second), "disk-6", false))
|
||||
if err != nil || transition.To != StateFiring {
|
||||
t.Fatalf("pre-recovery boundary transition = %#v, %v", transition, err)
|
||||
}
|
||||
transition, err = Transition(transition.Snapshot, policy, observation(start.Add(660*time.Second), "disk-7", false))
|
||||
if err != nil || transition.To != StateResolved {
|
||||
t.Fatalf("recovery boundary transition = %#v, %v", transition, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCooldownSuppressesRepeatedFiringNotification(t *testing.T) {
|
||||
start := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.UTC)
|
||||
policy := Policy{PendingSeconds: 0, ResolveSeconds: 0, CooldownSeconds: 60, UnknownBehavior: UnknownRetain}
|
||||
first, err := Transition(Snapshot{State: StateInactive, RetainedState: StateInactive}, policy, observation(start, "cool-1", true))
|
||||
if err != nil || first.Notification != NotificationFiring {
|
||||
t.Fatalf("first notification = %#v, %v", first, err)
|
||||
}
|
||||
resolved, err := Transition(first.Snapshot, policy, observation(start.Add(time.Second), "cool-2", false))
|
||||
if err != nil || resolved.To != StateResolved || resolved.Notification != NotificationRecovery || resolved.Snapshot.CooldownUntil == nil {
|
||||
t.Fatalf("resolve notification = %#v, %v", resolved, err)
|
||||
}
|
||||
suppressed, err := Transition(resolved.Snapshot, policy, observation(start.Add(30*time.Second), "cool-3", true))
|
||||
if err != nil || suppressed.To != StateFiring || suppressed.Notification != NotificationNone {
|
||||
t.Fatalf("cooldown firing notification = %#v, %v", suppressed, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/metriccatalog"
|
||||
)
|
||||
|
||||
const SchemaVersion = 1
|
||||
|
||||
const (
|
||||
SeverityAttention = "attention"
|
||||
SeverityDegraded = "degraded"
|
||||
SeverityCritical = "critical"
|
||||
UnknownRetain = "retain-firing-as-unknown"
|
||||
UnknownBecome = "become-unknown"
|
||||
UnknownIgnoreGap = "ignore-short-gap"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRule = errors.New("invalid alert rule")
|
||||
ErrConflict = errors.New("alert rule revision conflict")
|
||||
ErrNotFound = errors.New("alert rule not found")
|
||||
ErrUnavailable = errors.New("alert rule repository is unavailable")
|
||||
semanticKeyPattern = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_.-]*$")
|
||||
uuidPattern = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$")
|
||||
)
|
||||
|
||||
type Condition struct {
|
||||
InputType string `json:"inputType"`
|
||||
Metric string `json:"metric,omitempty"`
|
||||
Operator string `json:"operator"`
|
||||
Threshold any `json:"threshold,omitempty"`
|
||||
RecoveryThreshold *float64 `json:"recoveryThreshold,omitempty"`
|
||||
Aggregation string `json:"aggregation,omitempty"`
|
||||
WindowSeconds int `json:"windowSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
TitleKey string `json:"titleKey"`
|
||||
BodyKey string `json:"bodyKey"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Severity string `json:"severity"`
|
||||
Scope map[string]any `json:"scope"`
|
||||
Condition Condition `json:"condition"`
|
||||
EvaluationIntervalSeconds int `json:"evaluationIntervalSeconds"`
|
||||
PendingSeconds int `json:"pendingSeconds"`
|
||||
ResolveSeconds int `json:"resolveSeconds"`
|
||||
CooldownSeconds int `json:"cooldownSeconds,omitempty"`
|
||||
UnknownBehavior string `json:"unknownBehavior"`
|
||||
GroupBy []string `json:"groupBy,omitempty"`
|
||||
SuppressWhen []string `json:"suppressWhen,omitempty"`
|
||||
Message Message `json:"message"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
Document
|
||||
Revision int64 `json:"revision"`
|
||||
CurrentVersion int `json:"currentVersion"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Version struct {
|
||||
ID string `json:"id"`
|
||||
RuleID string `json:"ruleId"`
|
||||
VersionNumber int `json:"versionNumber"`
|
||||
Document Document `json:"document"`
|
||||
ChangeSummary string `json:"changeSummary"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type PreviewRequest struct {
|
||||
Value any `json:"value,omitempty"`
|
||||
Unknown bool `json:"unknown,omitempty"`
|
||||
CurrentState string `json:"currentState,omitempty"`
|
||||
}
|
||||
|
||||
type PreviewResult struct {
|
||||
WouldFire bool `json:"wouldFire"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (d Document) Validate(registry metriccatalog.Registry) error {
|
||||
if d.SchemaVersion != SchemaVersion {
|
||||
return invalid("schemaVersion", "must be 1")
|
||||
}
|
||||
if !uuidPattern.MatchString(d.ID) {
|
||||
return invalid("id", "must be a UUID")
|
||||
}
|
||||
if strings.TrimSpace(d.Name) != d.Name || d.Name == "" || len(d.Name) > 160 || strings.ContainsAny(d.Name, "\r\n") {
|
||||
return invalid("name", "must be 1-160 characters without line breaks")
|
||||
}
|
||||
if !oneOf(d.Severity, SeverityAttention, SeverityDegraded, SeverityCritical) {
|
||||
return invalid("severity", "is unsupported")
|
||||
}
|
||||
if len(d.Scope) > 20 {
|
||||
return invalid("scope", "has too many keys")
|
||||
}
|
||||
for key, value := range d.Scope {
|
||||
if len(key) == 0 || len(key) > 80 || !semanticKeyPattern.MatchString(key) {
|
||||
return invalid("scope", "contains an invalid key")
|
||||
}
|
||||
if err := validateValue(value, 160); err != nil {
|
||||
return invalid("scope", err.Error())
|
||||
}
|
||||
}
|
||||
if d.EvaluationIntervalSeconds < 5 || d.EvaluationIntervalSeconds > 3600 {
|
||||
return invalid("evaluationIntervalSeconds", "must be between 5 and 3600")
|
||||
}
|
||||
if d.PendingSeconds < 0 || d.PendingSeconds > 2592000 || d.ResolveSeconds < 0 || d.ResolveSeconds > 2592000 {
|
||||
return invalid("pendingSeconds", "is out of range")
|
||||
}
|
||||
if d.CooldownSeconds < 0 || d.CooldownSeconds > 2592000 {
|
||||
return invalid("cooldownSeconds", "is out of range")
|
||||
}
|
||||
|
||||
if !oneOf(d.UnknownBehavior, UnknownRetain, UnknownBecome, UnknownIgnoreGap) {
|
||||
return invalid("unknownBehavior", "is unsupported")
|
||||
}
|
||||
if len(d.GroupBy) > 10 || uniqueStrings(d.GroupBy, 80) != nil {
|
||||
return invalid("groupBy", "must contain at most 10 unique bounded keys")
|
||||
}
|
||||
if len(d.SuppressWhen) > 20 || uniqueStrings(d.SuppressWhen, 160) != nil {
|
||||
return invalid("suppressWhen", "must contain at most 20 unique bounded rules")
|
||||
}
|
||||
if len(d.Message.TitleKey) == 0 || len(d.Message.TitleKey) > 160 || len(d.Message.BodyKey) == 0 || len(d.Message.BodyKey) > 160 {
|
||||
return invalid("message", "titleKey and bodyKey are required and bounded")
|
||||
}
|
||||
if err := d.Condition.validate(registry); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Condition) validate(registry metriccatalog.Registry) error {
|
||||
if !oneOf(c.InputType, "metric", "entity-status", "event", "datasource-health") {
|
||||
return invalid("condition.inputType", "is unsupported")
|
||||
}
|
||||
if !oneOf(c.Operator, ">", ">=", "<", "<=", "==", "!=", "matches", "absent") {
|
||||
return invalid("condition.operator", "is unsupported")
|
||||
}
|
||||
if c.InputType == "metric" {
|
||||
if c.Metric == "" || strings.ContainsAny(c.Metric, "{};$()[]") || len(c.Metric) > 160 {
|
||||
return invalid("condition.metric", "must be a semantic metric name")
|
||||
}
|
||||
if _, ok := registry.Find(c.Metric); !ok {
|
||||
return invalid("condition.metric", "is not in the semantic metric catalog")
|
||||
}
|
||||
} else if c.Metric != "" {
|
||||
return invalid("condition.metric", "is only valid for metric input")
|
||||
}
|
||||
if c.Aggregation != "" && !oneOf(c.Aggregation, "none", "avg", "sum", "min", "max", "rate", "increase", "count", "p50", "p95", "p99") {
|
||||
return invalid("condition.aggregation", "is unsupported")
|
||||
}
|
||||
if c.Aggregation == "count" && c.InputType != "event" {
|
||||
return invalid("condition.aggregation", "count is only valid for event input")
|
||||
}
|
||||
if c.WindowSeconds < 0 || c.WindowSeconds > 2592000 {
|
||||
return invalid("condition.windowSeconds", "is out of range")
|
||||
}
|
||||
if c.Operator == "matches" {
|
||||
value, ok := c.Threshold.(string)
|
||||
if !ok || len(value) == 0 || len(value) > 160 {
|
||||
return invalid("condition.threshold", "matches requires a bounded pattern")
|
||||
}
|
||||
if _, err := regexp.Compile(value); err != nil {
|
||||
return invalid("condition.threshold", "contains an invalid pattern")
|
||||
}
|
||||
} else if c.Operator == "absent" {
|
||||
if c.Threshold != nil {
|
||||
return invalid("condition.threshold", "absent does not accept a threshold")
|
||||
}
|
||||
} else if !isNumber(c.Threshold) && !(c.Operator == "==" || c.Operator == "!=" && isComparableString(c.Threshold)) {
|
||||
return invalid("condition.threshold", "a numeric threshold is required")
|
||||
}
|
||||
if c.RecoveryThreshold != nil {
|
||||
if math.IsNaN(*c.RecoveryThreshold) || math.IsInf(*c.RecoveryThreshold, 0) {
|
||||
return invalid("condition.recoveryThreshold", "must be finite")
|
||||
}
|
||||
if !oneOf(c.Operator, ">", ">=", "<", "<=") || !isNumber(c.Threshold) {
|
||||
return invalid("condition.recoveryThreshold", "requires a numeric ordered condition")
|
||||
}
|
||||
threshold, _ := number(c.Threshold)
|
||||
if (c.Operator == ">" || c.Operator == ">=") && *c.RecoveryThreshold >= threshold {
|
||||
return invalid("condition.recoveryThreshold", "must be below the firing threshold")
|
||||
}
|
||||
if (c.Operator == "<" || c.Operator == "<=") && *c.RecoveryThreshold <= threshold {
|
||||
return invalid("condition.recoveryThreshold", "must be above the firing threshold")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Document) MarshalCanonical() ([]byte, error) {
|
||||
if d.Scope == nil {
|
||||
d.Scope = map[string]any{}
|
||||
}
|
||||
if d.GroupBy == nil {
|
||||
d.GroupBy = []string{}
|
||||
}
|
||||
if d.SuppressWhen == nil {
|
||||
d.SuppressWhen = []string{}
|
||||
}
|
||||
return json.Marshal(d)
|
||||
}
|
||||
|
||||
func DecodeDocument(data []byte, registry metriccatalog.Registry) (Document, error) {
|
||||
if len(data) == 0 || len(data) > 2<<20 {
|
||||
return Document{}, invalid("document", "is empty or too large")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
var document Document
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return Document{}, fmt.Errorf("%w: document: %v", ErrInvalidRule, err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return Document{}, invalid("document", "contains multiple JSON values")
|
||||
}
|
||||
if err := document.Validate(registry); err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func Preview(document Document, request PreviewRequest, registry metriccatalog.Registry) (PreviewResult, error) {
|
||||
if err := document.Validate(registry); err != nil {
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
if request.Unknown {
|
||||
if document.UnknownBehavior == UnknownRetain {
|
||||
return PreviewResult{State: "unknown", Reason: "unknown_input_retains_last_state"}, nil
|
||||
}
|
||||
return PreviewResult{State: "unknown", Reason: "unknown_input"}, nil
|
||||
}
|
||||
active := request.CurrentState == string(StateFiring) || request.CurrentState == string(StateAcknowledged) || request.CurrentState == string(StateUnknown)
|
||||
fire, err := document.Condition.Evaluate(request.Value, active)
|
||||
if err != nil {
|
||||
return PreviewResult{}, err
|
||||
}
|
||||
state := "inactive"
|
||||
if fire {
|
||||
state = "firing"
|
||||
}
|
||||
return PreviewResult{WouldFire: fire, State: state, Reason: "condition_evaluated"}, nil
|
||||
}
|
||||
|
||||
func (c Condition) Evaluate(value any, active bool) (bool, error) {
|
||||
threshold := c.Threshold
|
||||
operator := c.Operator
|
||||
if active && c.RecoveryThreshold != nil {
|
||||
threshold = *c.RecoveryThreshold
|
||||
if operator == ">" || operator == ">=" {
|
||||
operator = ">="
|
||||
}
|
||||
if operator == "<" || operator == "<=" {
|
||||
operator = "<="
|
||||
}
|
||||
}
|
||||
return compare(operator, value, threshold)
|
||||
}
|
||||
func compare(operator string, value, threshold any) (bool, error) {
|
||||
if operator == "absent" {
|
||||
return value == nil, nil
|
||||
}
|
||||
if operator == "matches" {
|
||||
left, ok := value.(string)
|
||||
right, ok2 := threshold.(string)
|
||||
if !ok || !ok2 {
|
||||
return false, invalid("preview", "matches requires string input")
|
||||
}
|
||||
matched, err := regexp.MatchString(right, left)
|
||||
return matched, err
|
||||
}
|
||||
if left, ok := number(value); ok {
|
||||
right, ok := number(threshold)
|
||||
if !ok {
|
||||
return false, invalid("preview", "numeric threshold is required")
|
||||
}
|
||||
switch operator {
|
||||
case ">":
|
||||
return left > right, nil
|
||||
case ">=":
|
||||
return left >= right, nil
|
||||
case "<":
|
||||
return left < right, nil
|
||||
case "<=":
|
||||
return left <= right, nil
|
||||
case "==":
|
||||
return left == right, nil
|
||||
case "!=":
|
||||
return left != right, nil
|
||||
}
|
||||
}
|
||||
if operator == "==" || operator == "!=" {
|
||||
equal := fmt.Sprint(value) == fmt.Sprint(threshold)
|
||||
if operator == "!=" {
|
||||
equal = !equal
|
||||
}
|
||||
return equal, nil
|
||||
}
|
||||
return false, invalid("preview", "value type does not support operator")
|
||||
}
|
||||
|
||||
func number(value any) (float64, bool) {
|
||||
switch value := value.(type) {
|
||||
case float64:
|
||||
return value, !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||
case float32:
|
||||
return float64(value), true
|
||||
case int:
|
||||
return float64(value), true
|
||||
case int64:
|
||||
return float64(value), true
|
||||
case json.Number:
|
||||
parsed, err := value.Float64()
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func isNumber(value any) bool { _, ok := number(value); return ok }
|
||||
func isComparableString(value any) bool { _, ok := value.(string); return ok }
|
||||
|
||||
func validateValue(value any, maxString int) error {
|
||||
switch value := value.(type) {
|
||||
case nil, bool:
|
||||
return nil
|
||||
case string:
|
||||
if len(value) > maxString || strings.ContainsAny(value, "\r\n") {
|
||||
return errors.New("contains an unsafe string")
|
||||
}
|
||||
case float64:
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return errors.New("contains a non-finite number")
|
||||
}
|
||||
case []any:
|
||||
if len(value) > 20 {
|
||||
return errors.New("contains too many values")
|
||||
}
|
||||
for _, item := range value {
|
||||
if err := validateValue(item, maxString); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
if len(value) > 20 {
|
||||
return errors.New("contains too many keys")
|
||||
}
|
||||
keys := make([]string, 0, len(value))
|
||||
for key, item := range value {
|
||||
keys = append(keys, key)
|
||||
if err := validateValue(item, maxString); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
default:
|
||||
return errors.New("contains an unsupported value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string, max int) error {
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
if len(value) == 0 || len(value) > max || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\r\n") {
|
||||
return errors.New("contains an invalid value")
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return errors.New("contains duplicate values")
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func oneOf(value string, allowed ...string) bool {
|
||||
for _, item := range allowed {
|
||||
if value == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func invalid(field, detail string) error {
|
||||
return fmt.Errorf("%w: %s %s", ErrInvalidRule, field, detail)
|
||||
}
|
||||
|
||||
func NewID() string {
|
||||
var raw [16]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "00000000-0000-4000-8000-000000000000"
|
||||
}
|
||||
raw[6] = (raw[6] & 0x0f) | 0x40
|
||||
raw[8] = (raw[8] & 0x3f) | 0x80
|
||||
encoded := hex.EncodeToString(raw[:])
|
||||
return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/itworx/pulse/internal/metriccatalog"
|
||||
)
|
||||
|
||||
func validDocument(t *testing.T) (Document, metriccatalog.Registry) {
|
||||
t.Helper()
|
||||
registry, err := metriccatalog.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Document{
|
||||
SchemaVersion: 1, ID: NewID(), Name: "CPU aandacht", Enabled: false, Severity: SeverityAttention,
|
||||
Scope: map[string]any{"entityType": "host"},
|
||||
Condition: Condition{InputType: "metric", Metric: registry.Metrics()[0].SemanticName, Operator: ">", Threshold: float64(80), Aggregation: "avg", WindowSeconds: 60},
|
||||
EvaluationIntervalSeconds: 30, PendingSeconds: 60, ResolveSeconds: 120, CooldownSeconds: 60, UnknownBehavior: UnknownRetain,
|
||||
GroupBy: []string{"instance"}, SuppressWhen: []string{"maintenance"}, Message: Message{TitleKey: "alerts.cpu.title", BodyKey: "alerts.cpu.body"},
|
||||
}, registry
|
||||
}
|
||||
|
||||
func TestDocumentValidationRejectsRawPromQLAndUnknownMetric(t *testing.T) {
|
||||
document, registry := validDocument(t)
|
||||
document.Condition.Metric = "rate(node_cpu_seconds_total[5m])"
|
||||
if !errors.Is(document.Validate(registry), ErrInvalidRule) {
|
||||
t.Fatal("expected raw query rejection")
|
||||
}
|
||||
document.Condition.Metric = "pulse.not_in_catalog"
|
||||
if !errors.Is(document.Validate(registry), ErrInvalidRule) {
|
||||
t.Fatal("expected unknown metric rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewHasDeterministicNoSideEffectEvaluation(t *testing.T) {
|
||||
document, registry := validDocument(t)
|
||||
result, err := Preview(document, PreviewRequest{Value: float64(90)}, registry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.WouldFire || result.State != "firing" || result.Reason != "condition_evaluated" {
|
||||
t.Fatalf("unexpected preview: %#v", result)
|
||||
}
|
||||
unknown, err := Preview(document, PreviewRequest{Unknown: true}, registry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unknown.WouldFire || unknown.State != "unknown" || unknown.Reason != "unknown_input_retains_last_state" {
|
||||
t.Fatalf("unexpected unknown preview: %#v", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDocumentRejectsUnknownFields(t *testing.T) {
|
||||
_, registry := validDocument(t)
|
||||
if _, err := DecodeDocument([]byte("{\"schemaVersion\":1,\"id\":\""+NewID()+"\",\"name\":\"x\",\"unsafeQuery\":\"rate(foo[5m])\"}"), registry); !errors.Is(err, ErrInvalidRule) {
|
||||
t.Fatal("expected unknown field rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluatorAppliesDiskTemperatureHysteresisAtBoundaries(t *testing.T) {
|
||||
document, registry := validDocument(t)
|
||||
document.Condition.Operator = ">="
|
||||
document.Condition.Threshold = float64(50)
|
||||
recovery := float64(46)
|
||||
document.Condition.RecoveryThreshold = &recovery
|
||||
if err := document.Validate(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firing, err := document.Condition.Evaluate(float64(49), false); err != nil || firing {
|
||||
t.Fatalf("activation at 49 = %v, %v; want false", firing, err)
|
||||
}
|
||||
for _, value := range []float64{49, 46} {
|
||||
firing, err := document.Condition.Evaluate(value, true)
|
||||
if err != nil || !firing {
|
||||
t.Fatalf("active evaluation at %v = %v, %v; want true", value, firing, err)
|
||||
}
|
||||
}
|
||||
if firing, err := document.Condition.Evaluate(float64(45.999), true); err != nil || firing {
|
||||
t.Fatalf("recovery at 45.999 = %v, %v; want false", firing, err)
|
||||
}
|
||||
recovery = 50
|
||||
if err := document.Validate(registry); !errors.Is(err, ErrInvalidRule) {
|
||||
t.Fatalf("inverted recovery threshold error = %v, want ErrInvalidRule", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user