This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
package alertdefaults
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/itworx/pulse/internal/alert"
|
||||
"github.com/itworx/pulse/internal/metriccatalog"
|
||||
)
|
||||
|
||||
//go:embed seed.json
|
||||
var seedFS embed.FS
|
||||
|
||||
type seedDocument struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Rules []alert.Document `json:"rules"`
|
||||
}
|
||||
|
||||
type RuleStore interface {
|
||||
Create(context.Context, string, alert.Document, string) (alert.Rule, alert.Version, error)
|
||||
Get(context.Context, string) (alert.Rule, error)
|
||||
// Update is optional for the seed: stores that also implement it allow the
|
||||
// seed to refresh an implementation-owned default that has never been edited
|
||||
// (revision 1) when a newer seed corrects it. See Seed.
|
||||
}
|
||||
|
||||
// RuleUpdater is implemented by stores that support versioned updates. The seed
|
||||
// uses it only for rules that are still at revision 1 (created by the seed and
|
||||
// never changed by an operator), so operator edits are never overwritten.
|
||||
type RuleUpdater interface {
|
||||
Update(ctx context.Context, id, actor string, expected int64, document alert.Document, changeSummary string) (alert.Rule, error)
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
Added int
|
||||
Existing int
|
||||
Refreshed int
|
||||
}
|
||||
|
||||
func Load(registry metriccatalog.Registry) ([]alert.Document, error) {
|
||||
raw, err := seedFS.ReadFile("seed.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded alert defaults: %w", err)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var bundle seedDocument
|
||||
if err := decoder.Decode(&bundle); err != nil {
|
||||
return nil, fmt.Errorf("decode embedded alert defaults: %w", err)
|
||||
}
|
||||
if bundle.SchemaVersion != 1 || len(bundle.Rules) == 0 {
|
||||
return nil, errors.New("embedded alert defaults have an invalid bundle")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(bundle.Rules))
|
||||
for _, rule := range bundle.Rules {
|
||||
if _, ok := seen[rule.ID]; ok {
|
||||
return nil, fmt.Errorf("embedded alert defaults contain duplicate rule %q", rule.ID)
|
||||
}
|
||||
seen[rule.ID] = struct{}{}
|
||||
if err := rule.Validate(registry); err != nil {
|
||||
return nil, fmt.Errorf("validate embedded alert default %q: %w", rule.ID, err)
|
||||
}
|
||||
}
|
||||
return bundle.Rules, nil
|
||||
}
|
||||
|
||||
func Seed(ctx context.Context, store RuleStore, registry metriccatalog.Registry, actor string) (Report, error) {
|
||||
if store == nil {
|
||||
return Report{}, alert.ErrUnavailable
|
||||
}
|
||||
rules, err := Load(registry)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
report := Report{}
|
||||
for _, document := range rules {
|
||||
if _, _, err := store.Create(ctx, actor, document, "system default seed v1"); err == nil {
|
||||
report.Added++
|
||||
} else if errors.Is(err, alert.ErrConflict) {
|
||||
existing, getErr := store.Get(ctx, document.ID)
|
||||
if getErr != nil {
|
||||
return Report{}, fmt.Errorf("verify existing alert default %q: %w", document.ID, getErr)
|
||||
}
|
||||
if updater, ok := store.(RuleUpdater); ok && existing.Revision == 1 && !sameDocument(existing.Document, document) {
|
||||
// The stored default is untouched since the seed created it, but the
|
||||
// seed itself changed (for example a corrected metric binding). Refresh
|
||||
// it through the normal versioned update path so history is kept.
|
||||
if _, updErr := updater.Update(ctx, document.ID, actor, existing.Revision, document, "system default seed v1 refresh"); updErr != nil {
|
||||
return Report{}, fmt.Errorf("refresh alert default %q: %w", document.ID, updErr)
|
||||
}
|
||||
report.Refreshed++
|
||||
continue
|
||||
}
|
||||
report.Existing++
|
||||
} else {
|
||||
return Report{}, fmt.Errorf("seed alert default %q: %w", document.ID, err)
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// sameDocument compares two rule documents by their canonical JSON encoding.
|
||||
func sameDocument(a, b alert.Document) bool {
|
||||
left, errA := json.Marshal(a)
|
||||
right, errB := json.Marshal(b)
|
||||
return errA == nil && errB == nil && bytes.Equal(left, right)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"rules": [
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "71111111-1111-4111-8111-111111111111",
|
||||
"name": "Monitoringbron levert geen recente gegevens",
|
||||
"enabled": true,
|
||||
"severity": "degraded",
|
||||
"scope": {
|
||||
"entityType": "data-source",
|
||||
"required": true
|
||||
},
|
||||
"condition": {
|
||||
"inputType": "datasource-health",
|
||||
"operator": "==",
|
||||
"threshold": "stale",
|
||||
"windowSeconds": 120
|
||||
},
|
||||
"evaluationIntervalSeconds": 30,
|
||||
"pendingSeconds": 120,
|
||||
"resolveSeconds": 60,
|
||||
"unknownBehavior": "become-unknown",
|
||||
"groupBy": [
|
||||
"source"
|
||||
],
|
||||
"suppressWhen": [],
|
||||
"message": {
|
||||
"titleKey": "alerts.datasourceStale.title",
|
||||
"bodyKey": "alerts.datasourceStale.body"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "81111111-1111-4111-8111-111111111111",
|
||||
"name": "Container bevindt zich in een herstartlus",
|
||||
"enabled": true,
|
||||
"severity": "degraded",
|
||||
"scope": {
|
||||
"entityType": "container",
|
||||
"excludeIntentionalStopped": true
|
||||
},
|
||||
"condition": {
|
||||
"inputType": "event",
|
||||
"operator": ">=",
|
||||
"threshold": 3,
|
||||
"aggregation": "count",
|
||||
"windowSeconds": 900
|
||||
},
|
||||
"evaluationIntervalSeconds": 30,
|
||||
"pendingSeconds": 0,
|
||||
"resolveSeconds": 900,
|
||||
"unknownBehavior": "become-unknown",
|
||||
"groupBy": [
|
||||
"container",
|
||||
"application"
|
||||
],
|
||||
"suppressWhen": [
|
||||
"host.unreachable"
|
||||
],
|
||||
"message": {
|
||||
"titleKey": "alerts.containerRestartLoop.title",
|
||||
"bodyKey": "alerts.containerRestartLoop.body"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "91111111-1111-4111-8111-111111111111",
|
||||
"name": "Disktemperatuur te hoog",
|
||||
"enabled": true,
|
||||
"severity": "degraded",
|
||||
"scope": {
|
||||
"entityType": "disk"
|
||||
},
|
||||
"condition": {
|
||||
"inputType": "metric",
|
||||
"metric": "storage.disk.temperature.maximum",
|
||||
"operator": ">=",
|
||||
"threshold": 50,
|
||||
"recoveryThreshold": 46,
|
||||
"aggregation": "max",
|
||||
"windowSeconds": 300
|
||||
},
|
||||
"evaluationIntervalSeconds": 30,
|
||||
"pendingSeconds": 300,
|
||||
"resolveSeconds": 300,
|
||||
"unknownBehavior": "retain-firing-as-unknown",
|
||||
"groupBy": [
|
||||
"disk",
|
||||
"server"
|
||||
],
|
||||
"suppressWhen": [
|
||||
"host.unreachable",
|
||||
"storage.source.unavailable"
|
||||
],
|
||||
"message": {
|
||||
"titleKey": "alerts.diskTemperature.title",
|
||||
"bodyKey": "alerts.diskTemperature.body"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "a1111111-1111-4111-8111-111111111111",
|
||||
"name": "Service is niet bereikbaar",
|
||||
"enabled": true,
|
||||
"severity": "degraded",
|
||||
"scope": {
|
||||
"entityType": "service",
|
||||
"critical": true
|
||||
},
|
||||
"condition": {
|
||||
"inputType": "metric",
|
||||
"metric": "service.availability.minimum",
|
||||
"operator": "<",
|
||||
"threshold": 1,
|
||||
"aggregation": "min",
|
||||
"windowSeconds": 90
|
||||
},
|
||||
"evaluationIntervalSeconds": 30,
|
||||
"pendingSeconds": 90,
|
||||
"resolveSeconds": 60,
|
||||
"unknownBehavior": "become-unknown",
|
||||
"groupBy": [
|
||||
"service",
|
||||
"application"
|
||||
],
|
||||
"suppressWhen": [
|
||||
"host.unreachable",
|
||||
"network.gateway.unreachable",
|
||||
"dns.unavailable"
|
||||
],
|
||||
"message": {
|
||||
"titleKey": "alerts.serviceUnavailable.title",
|
||||
"bodyKey": "alerts.serviceUnavailable.body"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "b1111111-1111-4111-8111-111111111111",
|
||||
"name": "Opslagpool bijna vol",
|
||||
"enabled": true,
|
||||
"severity": "critical",
|
||||
"scope": {
|
||||
"entityType": "storage_pool"
|
||||
},
|
||||
"condition": {
|
||||
"inputType": "metric",
|
||||
"metric": "storage.pool.utilization.maximum",
|
||||
"operator": ">=",
|
||||
"threshold": 97,
|
||||
"recoveryThreshold": 90,
|
||||
"aggregation": "max",
|
||||
"windowSeconds": 300
|
||||
},
|
||||
"evaluationIntervalSeconds": 60,
|
||||
"pendingSeconds": 300,
|
||||
"resolveSeconds": 300,
|
||||
"unknownBehavior": "retain-firing-as-unknown",
|
||||
"groupBy": [
|
||||
"server"
|
||||
],
|
||||
"suppressWhen": [
|
||||
"host.unreachable",
|
||||
"storage.source.unavailable"
|
||||
],
|
||||
"message": {
|
||||
"titleKey": "alerts.storagePoolCritical.title",
|
||||
"bodyKey": "alerts.storagePoolCritical.body"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package alertdefaults
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/alert"
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
"github.com/itworx/pulse/internal/metriccatalog"
|
||||
)
|
||||
|
||||
func TestPostgreSQLDefaultSeedIsIdempotent(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(), 60*time.Second)
|
||||
defer cancel()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 6, MinConns: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := metriccatalog.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := alert.Repository{Pool: pool, Registry: registry}
|
||||
first, err := Seed(ctx, repository, registry, "system-defaults")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Seed(ctx, repository, registry, "system-defaults")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Added != 0 || second.Existing != first.Added+first.Existing {
|
||||
t.Fatalf("seed reports first=%+v second=%+v", first, second)
|
||||
}
|
||||
rules, err := Load(registry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, document := range rules {
|
||||
loaded, err := repository.Get(ctx, document.ID)
|
||||
if err != nil || loaded.ID != document.ID {
|
||||
t.Fatalf("default %s was not readable after restart-safe seed: rule=%+v err=%v", document.ID, loaded, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package alertdefaults
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/alert"
|
||||
"github.com/itworx/pulse/internal/metriccatalog"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
rules map[string]alert.Rule
|
||||
adds int
|
||||
}
|
||||
|
||||
func (store *fakeStore) Create(_ context.Context, _ string, document alert.Document, _ string) (alert.Rule, alert.Version, error) {
|
||||
if _, exists := store.rules[document.ID]; exists {
|
||||
return alert.Rule{}, alert.Version{}, alert.ErrConflict
|
||||
}
|
||||
store.adds++
|
||||
rule := alert.Rule{Document: document}
|
||||
store.rules[document.ID] = rule
|
||||
return rule, alert.Version{RuleID: document.ID, VersionNumber: 1}, nil
|
||||
}
|
||||
|
||||
func (store *fakeStore) Update(_ context.Context, id, _ string, expected int64, document alert.Document, _ string) (alert.Rule, error) {
|
||||
rule, exists := store.rules[id]
|
||||
if !exists {
|
||||
return alert.Rule{}, alert.ErrNotFound
|
||||
}
|
||||
if rule.Revision != expected {
|
||||
return alert.Rule{}, alert.ErrConflict
|
||||
}
|
||||
rule.Document = document
|
||||
rule.Revision++
|
||||
store.rules[id] = rule
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (store *fakeStore) Get(_ context.Context, id string) (alert.Rule, error) {
|
||||
rule, exists := store.rules[id]
|
||||
if !exists {
|
||||
return alert.Rule{}, alert.ErrNotFound
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func TestLoadValidatesImplementationOwnedDefaults(t *testing.T) {
|
||||
registry, err := metriccatalog.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, err := Load(registry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rules) < 4 {
|
||||
t.Fatalf("default rule count=%d, want at least 4", len(rules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedIsIdempotentAndDoesNotOverwriteExistingRule(t *testing.T) {
|
||||
registry, err := metriccatalog.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, err := Load(registry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom := rules[0]
|
||||
custom.Name = "Aangepaste naam"
|
||||
store := &fakeStore{rules: map[string]alert.Rule{custom.ID: {Document: custom}}}
|
||||
first, err := Seed(context.Background(), store, registry, "system-defaults")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Added != len(rules)-1 || first.Existing != 1 || store.adds != len(rules)-1 {
|
||||
t.Fatalf("first seed report=%+v adds=%d", first, store.adds)
|
||||
}
|
||||
second, err := Seed(context.Background(), store, registry, "system-defaults")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Added != 0 || second.Existing != len(rules) {
|
||||
t.Fatalf("second seed report=%+v", second)
|
||||
}
|
||||
loaded, err := store.Get(context.Background(), custom.ID)
|
||||
if err != nil || loaded.Name != "Aangepaste naam" {
|
||||
t.Fatalf("custom rule was overwritten: rule=%+v err=%v", loaded, err)
|
||||
}
|
||||
}
|
||||
func TestDefaultRulesReduceStormNoise(t *testing.T) {
|
||||
registry, err := metriccatalog.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, err := Load(registry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var serviceRule alert.Document
|
||||
for _, rule := range rules {
|
||||
if rule.Name == "Service is niet bereikbaar" {
|
||||
serviceRule = rule
|
||||
}
|
||||
}
|
||||
if serviceRule.ID == "" {
|
||||
t.Fatal("service default rule is missing")
|
||||
}
|
||||
at := time.Date(2026, time.January, 4, 12, 0, 0, 0, time.UTC)
|
||||
signals := make([]alert.Signal, 0, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
signals = append(signals, alert.Signal{InstanceID: "service-" + string(rune('a'+i)), RuleID: serviceRule.ID, RuleVersionID: "version-1", EntityID: "entity-" + string(rune('a'+i)), Severity: serviceRule.Severity, State: alert.StateFiring, Fingerprint: "fingerprint-" + string(rune('a'+i)), EvaluationKey: "evaluation-1", ObservedAt: at, Labels: map[string]string{"host": "pulse", "application": "media", "service": "svc-" + string(rune('a'+i))}, GroupBy: serviceRule.GroupBy, SuppressWhen: serviceRule.SuppressWhen})
|
||||
}
|
||||
deduplicated, err := alert.DeduplicateSignals(append(signals, signals[0]))
|
||||
if err != nil || len(deduplicated) != len(signals) {
|
||||
t.Fatalf("deduplicated=%d err=%v", len(deduplicated), err)
|
||||
}
|
||||
groups, err := alert.GroupSignals(deduplicated)
|
||||
if err != nil || len(groups) != len(signals) {
|
||||
t.Fatalf("groups=%+v err=%v", groups, err)
|
||||
}
|
||||
decision, err := alert.EvaluateSuppression(signals[0], []alert.Cause{{Key: "dns.unavailable", State: alert.StateFiring, Confirmed: true, ObservedAt: at}})
|
||||
if err != nil || !decision.Suppressed || decision.CauseKey != "dns.unavailable" {
|
||||
t.Fatalf("suppression=%+v err=%v", decision, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedRefreshesUntouchedDefaultButKeepsOperatorEdits(t *testing.T) {
|
||||
registry, err := metriccatalog.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules, err := Load(registry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := rules[0]
|
||||
stale.PendingSeconds = stale.PendingSeconds + 60
|
||||
edited := rules[1]
|
||||
edited.Name = "Door operator aangepast"
|
||||
store := &fakeStore{rules: map[string]alert.Rule{
|
||||
stale.ID: {Document: stale, Revision: 1}, // seeded, never edited -> refreshed
|
||||
edited.ID: {Document: edited, Revision: 2}, // edited by an operator -> untouched
|
||||
}}
|
||||
report, err := Seed(context.Background(), store, registry, "system-defaults")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Refreshed != 1 || report.Existing != 1 || report.Added != len(rules)-2 {
|
||||
t.Fatalf("report=%+v", report)
|
||||
}
|
||||
refreshed, _ := store.Get(context.Background(), stale.ID)
|
||||
if refreshed.PendingSeconds != rules[0].PendingSeconds || refreshed.Revision != 2 {
|
||||
t.Fatalf("stale default not refreshed: %+v", refreshed)
|
||||
}
|
||||
kept, _ := store.Get(context.Background(), edited.ID)
|
||||
if kept.Name != "Door operator aangepast" || kept.Revision != 2 {
|
||||
t.Fatalf("operator edit overwritten: %+v", kept)
|
||||
}
|
||||
again, err := Seed(context.Background(), store, registry, "system-defaults")
|
||||
if err != nil || again.Refreshed != 0 || again.Added != 0 {
|
||||
t.Fatalf("second seed not idempotent: %+v err=%v", again, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user