Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+104
View File
@@ -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
}
+230
View File
@@ -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)
}
+100
View File
@@ -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")
}
}
+99
View File
@@ -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), &current); 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), &current); 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)
}
}
+26
View File
@@ -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)
}
}
}
+357
View File
@@ -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(&currentRevision, &currentVersionID)
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(&currentJSON); 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(&currentVersion); 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, &current); 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)
}
}
+261
View File
@@ -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
}
+360
View File
@@ -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), &current); 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), &current); 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), &current); 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), &current); 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)
}
}
+167
View File
@@ -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)
}
}
+423
View File
@@ -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]
}
+87
View File
@@ -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)
}
}