diff --git a/grafana-alertcheck/internal/gate/duration.go b/grafana-alertcheck/internal/gate/duration.go new file mode 100644 index 000000000..011c27f14 --- /dev/null +++ b/grafana-alertcheck/internal/gate/duration.go @@ -0,0 +1,93 @@ +package gate + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// promDurationUnit is one accepted unit in a Prometheus-style duration string. +// rank increases with unit size; ParsePromDuration requires strictly decreasing +// rank across concatenated components (e.g. "1h30m", never "30m1h"). +type promDurationUnit struct { + suffix string + mult time.Duration + rank int +} + +// Longest suffix must be tried first ("ms" before "m") — see the matching loop below. +var promDurationUnits = []promDurationUnit{ + {"ms", time.Millisecond, 0}, + {"s", time.Second, 1}, + {"m", time.Minute, 2}, + {"h", time.Hour, 3}, + {"d", 24 * time.Hour, 4}, + {"w", 7 * 24 * time.Hour, 5}, + {"y", 365 * 24 * time.Hour, 6}, +} + +// ParsePromDuration parses a Grafana/Prometheus-style duration ("1h30m", "1d", "1w"). +// Unlike time.ParseDuration, it accepts "d" and "w" (§11.8). "" and "0" are 0. +func ParsePromDuration(s string) (time.Duration, error) { + if s == "" || s == "0" { + return 0, nil + } + if strings.HasPrefix(s, "-") { + return 0, fmt.Errorf("invalid duration %q: negative durations are not supported", s) + } + + var total time.Duration + prevRank := len(promDurationUnits) // sentinel higher than any real rank + rest := s + for rest != "" { + i := 0 + for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' { + i++ + } + if i == 0 { + return 0, fmt.Errorf("invalid duration %q: expected a number", s) + } + numPart := rest[:i] + rest = rest[i:] + + matched := -1 + matchLen := 0 + for idx, u := range promDurationUnits { + if strings.HasPrefix(rest, u.suffix) && len(u.suffix) > matchLen { + matched = idx + matchLen = len(u.suffix) + } + } + if matched == -1 { + return 0, fmt.Errorf("invalid duration %q: unrecognized unit", s) + } + u := promDurationUnits[matched] + if u.rank >= prevRank { + return 0, fmt.Errorf("invalid duration %q: units must appear in descending order", s) + } + prevRank = u.rank + + n, err := strconv.ParseInt(numPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + // n and u.mult are both non-negative here (the leading "-" check + // above rejects negative input), so overflow of either the + // multiplication or the running sum can only wrap upward past + // math.MaxInt64 — check both explicitly rather than let a duration + // like "300y" silently become negative garbage that would later + // feed transitionGrace. + if n != 0 && n > math.MaxInt64/int64(u.mult) { + return 0, fmt.Errorf("invalid duration %q: overflows time.Duration", s) + } + delta := time.Duration(n) * u.mult + if total > math.MaxInt64-delta { + return 0, fmt.Errorf("invalid duration %q: overflows time.Duration", s) + } + total += delta + rest = rest[matchLen:] + } + return total, nil +} diff --git a/grafana-alertcheck/internal/gate/duration_test.go b/grafana-alertcheck/internal/gate/duration_test.go new file mode 100644 index 000000000..73a0c00e1 --- /dev/null +++ b/grafana-alertcheck/internal/gate/duration_test.go @@ -0,0 +1,65 @@ +package gate + +import ( + "testing" + "time" +) + +func TestParsePromDuration(t *testing.T) { + cases := []struct { + in string + want time.Duration + }{ + {"", 0}, + {"0", 0}, + {"0s", 0}, + {"500ms", 500 * time.Millisecond}, + {"1s", time.Second}, + {"1m", time.Minute}, + {"2m", 2 * time.Minute}, + {"3m", 3 * time.Minute}, + {"5m", 5 * time.Minute}, + {"10m", 10 * time.Minute}, + {"15m", 15 * time.Minute}, + {"20m", 20 * time.Minute}, + {"30m", 30 * time.Minute}, + {"1h", time.Hour}, + {"6h", 6 * time.Hour}, + {"12h", 12 * time.Hour}, + {"1d", 24 * time.Hour}, + {"1w", 7 * 24 * time.Hour}, + {"1y", 365 * 24 * time.Hour}, + {"1h30m", time.Hour + 30*time.Minute}, + {"1s500ms", time.Second + 500*time.Millisecond}, + {"2d12h", 2*24*time.Hour + 12*time.Hour}, + } + for _, c := range cases { + got, err := ParsePromDuration(c.in) + if err != nil { + t.Errorf("ParsePromDuration(%q): unexpected error: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("ParsePromDuration(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestParsePromDuration_Errors(t *testing.T) { + cases := []string{ + "5", // bare number, no unit + "-5m", // negative + "5x", // unknown unit + "30m1h", // ascending order (must be descending) + "1h1h", // duplicate unit + "m", // unit with no number + "1.5h", // fractional number not supported by this grammar + "1 h", // whitespace + "300y", // overflows time.Duration (int64 nanoseconds) — must error, not wrap negative + } + for _, in := range cases { + if _, err := ParsePromDuration(in); err == nil { + t.Errorf("ParsePromDuration(%q): expected an error, got none", in) + } + } +} diff --git a/grafana-alertcheck/internal/gate/jsonreq.go b/grafana-alertcheck/internal/gate/jsonreq.go new file mode 100644 index 000000000..cea19b6fe --- /dev/null +++ b/grafana-alertcheck/internal/gate/jsonreq.go @@ -0,0 +1,39 @@ +package gate + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// req decodes m[key] into *dst. It returns an error when key is absent from m +// or explicitly JSON null, so a caller can never mistake absence for a zero +// value (H1) — json.Unmarshal treats "null" as a documented no-op for +// non-pointer targets (string, bool, int, ...), so without this check a +// required field sent as null would silently pass through as its zero value. +func req[T any](m map[string]json.RawMessage, key string, dst *T) error { + raw, ok := m[key] + if !ok || isJSONNull(raw) { + return fmt.Errorf("required field %q is absent", key) + } + if err := json.Unmarshal(raw, dst); err != nil { + return fmt.Errorf("field %q: %w", key, err) + } + return nil +} + +func isJSONNull(raw json.RawMessage) bool { + return string(bytes.TrimSpace(raw)) == "null" +} + +// opt decodes m[key] into *dst when present, leaving *dst untouched when key is absent. +func opt[T any](m map[string]json.RawMessage, key string, dst *T) error { + raw, ok := m[key] + if !ok { + return nil + } + if err := json.Unmarshal(raw, dst); err != nil { + return fmt.Errorf("field %q: %w", key, err) + } + return nil +} diff --git a/grafana-alertcheck/internal/gate/jsonreq_test.go b/grafana-alertcheck/internal/gate/jsonreq_test.go new file mode 100644 index 000000000..bf3881e4b --- /dev/null +++ b/grafana-alertcheck/internal/gate/jsonreq_test.go @@ -0,0 +1,92 @@ +package gate + +import ( + "encoding/json" + "testing" +) + +func rawMap(t *testing.T, jsonObj string) map[string]json.RawMessage { + t.Helper() + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(jsonObj), &m); err != nil { + t.Fatalf("rawMap: %v", err) + } + return m +} + +func TestReq(t *testing.T) { + m := rawMap(t, `{"present":"hello","wrongtype":123,"nullval":null}`) + + t.Run("present key decodes", func(t *testing.T) { + var s string + if err := req(m, "present", &s); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s != "hello" { + t.Errorf("got %q, want hello", s) + } + }) + + t.Run("absent key errors", func(t *testing.T) { + var s string + if err := req(m, "missing", &s); err == nil { + t.Fatalf("expected an error, got none") + } + }) + + t.Run("wrong type errors", func(t *testing.T) { + var s string + if err := req(m, "wrongtype", &s); err == nil { + t.Fatalf("expected an error, got none") + } + }) + + t.Run("explicit JSON null errors, never a zero value", func(t *testing.T) { + var s string + err := req(m, "nullval", &s) + if err == nil { + t.Fatalf("expected an error, got none (s=%q) — a null required field must not silently become a zero value", s) + } + }) +} + +func TestOpt(t *testing.T) { + m := rawMap(t, `{"present":"hello","wrongtype":123,"nullval":null}`) + + t.Run("present key decodes", func(t *testing.T) { + var s string + if err := opt(m, "present", &s); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s != "hello" { + t.Errorf("got %q, want hello", s) + } + }) + + t.Run("absent key leaves dst untouched", func(t *testing.T) { + s := "unchanged" + if err := opt(m, "missing", &s); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s != "unchanged" { + t.Errorf("got %q, want unchanged", s) + } + }) + + t.Run("wrong type errors", func(t *testing.T) { + var s string + if err := opt(m, "wrongtype", &s); err == nil { + t.Fatalf("expected an error, got none") + } + }) + + t.Run("explicit JSON null leaves dst at its zero value", func(t *testing.T) { + var s string + if err := opt(m, "nullval", &s); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s != "" { + t.Errorf("got %q, want empty string", s) + } + }) +} diff --git a/grafana-alertcheck/internal/gate/parse_ruler.go b/grafana-alertcheck/internal/gate/parse_ruler.go new file mode 100644 index 000000000..06f51407e --- /dev/null +++ b/grafana-alertcheck/internal/gate/parse_ruler.go @@ -0,0 +1,184 @@ +package gate + +import ( + "encoding/json" + "fmt" + "sort" + "time" +) + +// RuleKind classifies a ruler-endpoint rule by shape, not by name (P1.3). +// P3 rejects KindDatasourceManaged and KindRecording, but only for rules a +// user actually named — ParseDefinitions itself never rejects. +type RuleKind int + +const ( + KindGrafanaManaged RuleKind = iota + KindDatasourceManaged + KindRecording +) + +// Definition is one rule from the ruler endpoint +// (/api/ruler/grafana/api/v1/rules). IntervalSeconds, NoDataState and +// ExecErrState live inside the grafana_alert block and are only populated for +// KindGrafanaManaged — a datasource-managed rule has no such block by +// definition (§11.6 drops relativeTimeRange/keep_firing_for entirely; neither +// is parsed here). +type Definition struct { + UID, Title, Folder, FolderUID, Group string + For time.Duration + IntervalSeconds int + NoDataState string + ExecErrState string + IsPaused bool + Kind RuleKind +} + +// ParseDefinitions strictly parses a ruler-endpoint response body +// (map[namespace][]group) into its rule definitions. +func ParseDefinitions(body []byte) ([]Definition, error) { + var namespaces map[string][]json.RawMessage + if err := json.Unmarshal(body, &namespaces); err != nil { + return nil, fmt.Errorf("ruler response: %w", err) + } + + // Map iteration order is nondeterministic; sort namespace names so + // ParseDefinitions' output order is stable across calls (P3's candidate + // listings and any golden test depend on that). + names := make([]string, 0, len(namespaces)) + for name := range namespaces { + names = append(names, name) + } + sort.Strings(names) + + var defs []Definition + for _, folder := range names { + for gi, groupRaw := range namespaces[folder] { + var group map[string]json.RawMessage + if err := json.Unmarshal(groupRaw, &group); err != nil { + return nil, fmt.Errorf("ruler response: namespace %q: group %d: %w", folder, gi, err) + } + + var groupName string + if err := req(group, "name", &groupName); err != nil { + return nil, fmt.Errorf("ruler response: namespace %q: group %d: %w", folder, gi, err) + } + + var rulesRaw []json.RawMessage + if err := req(group, "rules", &rulesRaw); err != nil { + return nil, fmt.Errorf("ruler response: namespace %q: group %q: %w", folder, groupName, err) + } + + for ri, ruleRaw := range rulesRaw { + def, err := parseDefinition(ruleRaw, folder, groupName) + if err != nil { + return nil, fmt.Errorf("ruler response: namespace %q: group %q: rule %d: %w", folder, groupName, ri, err) + } + defs = append(defs, def) + } + } + } + return defs, nil +} + +func parseDefinition(raw json.RawMessage, folder, group string) (Definition, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return Definition{}, fmt.Errorf("%w", err) + } + + var forStr string + if err := opt(m, "for", &forStr); err != nil { + return Definition{}, err + } + forDur, err := ParsePromDuration(forStr) + if err != nil { + return Definition{}, fmt.Errorf("for: %w", err) + } + + var gaRaw json.RawMessage + if err := opt(m, "grafana_alert", &gaRaw); err != nil { + return Definition{}, err + } + if gaRaw == nil { + // No grafana_alert block at all: a datasource-managed (native + // Prometheus-format) rule. Its identity is the Prometheus rule + // name — "alert" for an alerting rule, "record" for a recording + // one — never a synthetic UID (Grafana's ruler API gives this + // shape no uid at all; inventing one would be inventing shape). + def := Definition{Folder: folder, Group: group, For: forDur, Kind: KindDatasourceManaged} + if err := opt(m, "alert", &def.Title); err != nil { + return Definition{}, err + } + if def.Title == "" { + if err := opt(m, "record", &def.Title); err != nil { + return Definition{}, err + } + } + return def, nil + } + + var ga map[string]json.RawMessage + if err := json.Unmarshal(gaRaw, &ga); err != nil { + return Definition{}, fmt.Errorf("grafana_alert: %w", err) + } + + // uid identifies the rule regardless of kind — every grafana_alert + // object Grafana emits, alerting or recording, carries one. + var uid string + if err := req(ga, "uid", &uid); err != nil { + return Definition{}, fmt.Errorf("grafana_alert: %w", err) + } + def := Definition{Folder: folder, Group: group, For: forDur, UID: uid} + + // Classify by the presence of "record" before requiring anything else. + // no_data_state/exec_err_state/is_paused/intervalSeconds are alerting-only + // concepts a recording rule may not carry at all — its real shape is + // unverified (none exist in the fleet capture) — and P3 refuses this + // Kind categorically before any of this would gate a release. Strict- + // parsing a recording rule into a hard error over fields it was never + // going to use would brick `list` and every resolve for rules nobody + // named (§11.6, "do not reject here"). + var record json.RawMessage + if err := opt(ga, "record", &record); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + if record != nil { + def.Kind = KindRecording + if err := opt(ga, "title", &def.Title); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + if err := opt(ga, "namespace_uid", &def.FolderUID); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + if err := opt(ga, "intervalSeconds", &def.IntervalSeconds); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + if err := opt(ga, "is_paused", &def.IsPaused); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + return def, nil + } + + def.Kind = KindGrafanaManaged + if err := req(ga, "title", &def.Title); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + if err := req(ga, "namespace_uid", &def.FolderUID); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + if err := req(ga, "intervalSeconds", &def.IntervalSeconds); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + if err := req(ga, "no_data_state", &def.NoDataState); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + if err := req(ga, "exec_err_state", &def.ExecErrState); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + if err := req(ga, "is_paused", &def.IsPaused); err != nil { + return Definition{}, fmt.Errorf("rule %q: grafana_alert: %w", uid, err) + } + + return def, nil +} diff --git a/grafana-alertcheck/internal/gate/parse_ruler_test.go b/grafana-alertcheck/internal/gate/parse_ruler_test.go new file mode 100644 index 000000000..56d35b663 --- /dev/null +++ b/grafana-alertcheck/internal/gate/parse_ruler_test.go @@ -0,0 +1,116 @@ +package gate + +import ( + "testing" + "time" +) + +func TestParseDefinitions_RulerRules(t *testing.T) { + defs, err := ParseDefinitions(readFixture(t, "ruler_rules.json")) + if err != nil { + t.Fatalf("ParseDefinitions: unexpected error: %v", err) + } + + byUID := map[string]Definition{} + for _, d := range defs { + if d.Kind != KindGrafanaManaged { + t.Errorf("rule %q: Kind = %v, want KindGrafanaManaged", d.UID, d.Kind) + } + byUID[d.UID] = d + } + + // The real 2-way duplicate title: same folder, same group, same title, + // distinct UIDs (§17, §22.2). + a, ok := byUID["rule0000006a"] + if !ok { + t.Fatalf("missing rule0000006a") + } + b, ok := byUID["rule0000006b"] + if !ok { + t.Fatalf("missing rule0000006b") + } + if a.Title != b.Title || a.Folder != b.Folder || a.Group != b.Group { + t.Errorf("duplicate-title pair should share Title/Folder/Group: a=%+v b=%+v", a, b) + } + if a.UID == b.UID { + t.Errorf("duplicate-title pair should have distinct UIDs") + } + + // The 3 real paused rules. + pausedUIDs := []string{"rule0000002", "rule0000007", "rule0000008"} + for _, uid := range pausedUIDs { + d, ok := byUID[uid] + if !ok { + t.Fatalf("missing paused rule %q", uid) + } + if !d.IsPaused { + t.Errorf("rule %q: IsPaused = false, want true", uid) + } + } + + // for:1d and the derived for:1w rule. + dayRule, ok := byUID["rule0000009"] + if !ok || dayRule.For != 24*time.Hour { + t.Fatalf("rule0000009: For = %v, want 24h (ok=%v)", dayRule.For, ok) + } + weekRule, ok := byUID["rule0000010"] + if !ok || weekRule.For != 7*24*time.Hour { + t.Fatalf("rule0000010: For = %v, want 168h (ok=%v)", weekRule.For, ok) + } + + // Identity shared with testdata/state_paused.json. + shared := byUID["rule0000002"] + if shared.FolderUID != "folder0000002" { + t.Errorf("rule0000002: FolderUID = %q, want folder0000002", shared.FolderUID) + } +} + +func TestParseDefinitions_DatasourceManaged(t *testing.T) { + defs, err := ParseDefinitions(readFixture(t, "ruler_datasource_managed.json")) + if err != nil { + t.Fatalf("ParseDefinitions: unexpected error: %v", err) + } + if len(defs) != 1 { + t.Fatalf("got %d definitions, want 1", len(defs)) + } + if defs[0].Kind != KindDatasourceManaged { + t.Errorf("Kind = %v, want KindDatasourceManaged", defs[0].Kind) + } + if defs[0].For != 5*time.Minute { + t.Errorf("For = %v, want 5m", defs[0].For) + } + // A datasource-managed rule has no uid in this shape; its only identity + // is the Prometheus "alert" name — a synthetic UID would be invented + // shape, and an empty Title would make P3's refusal-by-name unreachable. + if defs[0].Title != "ExampleTargetDown" { + t.Errorf("Title = %q, want ExampleTargetDown", defs[0].Title) + } + if defs[0].UID != "" { + t.Errorf("UID = %q, want empty (this shape has no uid)", defs[0].UID) + } +} + +func TestParseDefinitions_Recording(t *testing.T) { + defs, err := ParseDefinitions(readFixture(t, "ruler_recording.json")) + if err != nil { + t.Fatalf("ParseDefinitions: unexpected error: %v", err) + } + if len(defs) != 1 { + t.Fatalf("got %d definitions, want 1", len(defs)) + } + d := defs[0] + if d.Kind != KindRecording { + t.Errorf("Kind = %v, want KindRecording", d.Kind) + } + if d.UID != "rule0000011" { + t.Errorf("UID = %q, want rule0000011", d.UID) + } + // The fixture deliberately omits no_data_state/exec_err_state/is_paused/ + // intervalSeconds/namespace_uid — alerting-only concepts a recording + // rule may not carry. Requiring them would brick ParseDefinitions for + // every named rule in the same response over one recording rule + // elsewhere in the fleet; they must come back as zero values, not errors. + if d.NoDataState != "" || d.ExecErrState != "" || d.IsPaused || d.IntervalSeconds != 0 || d.FolderUID != "" { + t.Errorf("expected zero-valued alert-only fields for a recording rule, got %+v", d) + } +} diff --git a/grafana-alertcheck/internal/gate/parse_state.go b/grafana-alertcheck/internal/gate/parse_state.go new file mode 100644 index 000000000..ebd30d7a5 --- /dev/null +++ b/grafana-alertcheck/internal/gate/parse_state.go @@ -0,0 +1,269 @@ +package gate + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// State is the canonical instance state (P1.2a). It is distinct from the raw, +// unnormalized vocabularies the API uses at the rule level and at the instance +// level — see normalizeInstanceState. +type State string + +const ( + StateNormal State = "normal" + StateFiring State = "firing" + StatePending State = "pending" + StateNodata State = "nodata" + StateError State = "error" +) + +// Instance is one entry of a rule's alerts[]. State is always canonical; Reason +// is the opaque suffix of a "State (Reason)" composite ("" when the API gave a +// bare state). Reason is reporting-only except for the H2 MissingSeries routing +// done downstream in the log markers. +type Instance struct { + Labels map[string]string + State State + Reason string + ActiveAt time.Time + Value string +} + +// StateRule is one rule from the state endpoint +// (/api/prometheus/grafana/api/v1/rules), fully and strictly parsed (H1). +type StateRule struct { + UID, Title, Folder, Group string + Interval time.Duration + // State and Health are raw, lowercase, and reporting-only — never + // classified (P1.2a). State in particular is never normalized. + State, Health string + LastError string + LastEvaluation time.Time + IsPaused bool + Instances []Instance + Totals map[string]int +} + +// ParseState strictly parses a state-endpoint response body into its rules. +// A missing or unparseable required field (health, state, lastEvaluation on +// each rule; interval on each group) is an error, never a zero value (H1). +func ParseState(body []byte) ([]StateRule, error) { + var top map[string]json.RawMessage + if err := json.Unmarshal(body, &top); err != nil { + return nil, fmt.Errorf("state response: %w", err) + } + + var dataRaw json.RawMessage + if err := req(top, "data", &dataRaw); err != nil { + return nil, fmt.Errorf("state response: %w", err) + } + var data map[string]json.RawMessage + if err := json.Unmarshal(dataRaw, &data); err != nil { + return nil, fmt.Errorf("state response: data: %w", err) + } + + var groupsRaw []json.RawMessage + if err := req(data, "groups", &groupsRaw); err != nil { + return nil, fmt.Errorf("state response: %w", err) + } + + var rules []StateRule + for gi, groupRaw := range groupsRaw { + var group map[string]json.RawMessage + if err := json.Unmarshal(groupRaw, &group); err != nil { + return nil, fmt.Errorf("state response: group %d: %w", gi, err) + } + + var folder, groupName string + if err := req(group, "file", &folder); err != nil { + return nil, fmt.Errorf("state response: group %d: %w", gi, err) + } + if err := req(group, "name", &groupName); err != nil { + return nil, fmt.Errorf("state response: group %d (folder %q): %w", gi, folder, err) + } + + var intervalSeconds float64 + if err := req(group, "interval", &intervalSeconds); err != nil { + return nil, fmt.Errorf("state response: group %q: %w", groupName, err) + } + interval := time.Duration(intervalSeconds * float64(time.Second)) + + var rulesRaw []json.RawMessage + if err := req(group, "rules", &rulesRaw); err != nil { + return nil, fmt.Errorf("state response: group %q: %w", groupName, err) + } + + for ri, ruleRaw := range rulesRaw { + rule, err := parseStateRule(ruleRaw, folder, groupName, interval) + if err != nil { + return nil, fmt.Errorf("state response: group %q (folder %q): rule %d: %w", groupName, folder, ri, err) + } + rules = append(rules, rule) + } + } + return rules, nil +} + +func parseStateRule(raw json.RawMessage, folder, group string, interval time.Duration) (StateRule, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return StateRule{}, fmt.Errorf("rule: %w", err) + } + + var uid, name string + if err := req(m, "uid", &uid); err != nil { + return StateRule{}, fmt.Errorf("rule: %w", err) + } + if err := req(m, "name", &name); err != nil { + return StateRule{}, fmt.Errorf("rule %q: %w", uid, err) + } + + r := StateRule{UID: uid, Title: name, Folder: folder, Group: group, Interval: interval} + + if err := req(m, "state", &r.State); err != nil { + return StateRule{}, fmt.Errorf("rule %q: %w", uid, err) + } + if err := req(m, "health", &r.Health); err != nil { + return StateRule{}, fmt.Errorf("rule %q: %w", uid, err) + } + // isPaused is not one of H1's four named required fields, but this parser + // extends that contract to it: the zero-time rule below can't tell a + // paused rule from a broken one without it, and it's the primary + // in-window pause detector (H2/§12.2) — a silent false default would be + // exactly the fail-open bug H1 exists to kill. + if err := req(m, "isPaused", &r.IsPaused); err != nil { + return StateRule{}, fmt.Errorf("rule %q: %w", uid, err) + } + + var lastEvalStr string + if err := req(m, "lastEvaluation", &lastEvalStr); err != nil { + return StateRule{}, fmt.Errorf("rule %q: %w", uid, err) + } + lastEval, err := time.Parse(time.RFC3339, lastEvalStr) + if err != nil { + return StateRule{}, fmt.Errorf("rule %q: lastEvaluation: %w", uid, err) + } + // The zero-time rule (§2.3): only a paused rule may report the zero time. + if lastEval.IsZero() && !r.IsPaused { + return StateRule{}, fmt.Errorf("rule %q: lastEvaluation is the zero time but isPaused is false", uid) + } + r.LastEvaluation = lastEval + + if err := opt(m, "lastError", &r.LastError); err != nil { + return StateRule{}, fmt.Errorf("rule %q: %w", uid, err) + } + + if err := opt(m, "totals", &r.Totals); err != nil { + return StateRule{}, fmt.Errorf("rule %q: %w", uid, err) + } + + var alertsRaw []json.RawMessage + if err := opt(m, "alerts", &alertsRaw); err != nil { + return StateRule{}, fmt.Errorf("rule %q: %w", uid, err) + } + if alertsRaw != nil { + instances := make([]Instance, 0, len(alertsRaw)) + for ii, ar := range alertsRaw { + inst, err := parseInstance(ar) + if err != nil { + return StateRule{}, fmt.Errorf("rule %q: instance %d: %w", uid, ii, err) + } + instances = append(instances, inst) + } + r.Instances = instances + } + + return r, nil +} + +func parseInstance(raw json.RawMessage) (Instance, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return Instance{}, fmt.Errorf("%w", err) + } + + var rawState string + if err := req(m, "state", &rawState); err != nil { + return Instance{}, err + } + state, reason, err := normalizeInstanceState(rawState) + if err != nil { + return Instance{}, err + } + + // activeAt is also not in H1's named list, extended here for the same + // reason as StateRule.IsPaused: it's the onset time BadFor (P8) measures + // from, so a silently zeroed one would misclassify how long an instance + // has been bad rather than failing loudly. + var activeAtStr string + if err := req(m, "activeAt", &activeAtStr); err != nil { + return Instance{}, err + } + activeAt, err := time.Parse(time.RFC3339, activeAtStr) + if err != nil { + return Instance{}, fmt.Errorf("activeAt: %w", err) + } + + inst := Instance{State: state, Reason: reason, ActiveAt: activeAt} + + if err := opt(m, "value", &inst.Value); err != nil { + return Instance{}, err + } + if err := opt(m, "labels", &inst.Labels); err != nil { + return Instance{}, err + } + + return inst, nil +} + +// baseInstanceStates is the strict 5-value allowlist for the base of an +// instance state (P1.2a). Anything else — including an unrecognized base +// inside a "Base (Reason)" composite — is a parse error (H1, §2.7 control 3). +var baseInstanceStates = map[string]State{ + "Normal": StateNormal, + "Alerting": StateFiring, + "Pending": StatePending, + "NoData": StateNodata, + "Error": StateError, +} + +// normalizeInstanceState normalizes an instance-level state string into its +// canonical base and an opaque reason. The composite "State (Reason)" form is +// parsed structurally — split on the first " (" with a trailing ")" — never by +// enumerating composites, because Grafana's reason vocabulary grows across +// versions and an unknown reason must not break parsing. +func normalizeInstanceState(s string) (State, string, error) { + base, reason := s, "" + if i := strings.Index(s, " ("); i != -1 && strings.HasSuffix(s, ")") { + base = s[:i] + reason = s[i+2 : len(s)-1] + } + state, ok := baseInstanceStates[base] + if !ok { + return "", "", fmt.Errorf("unrecognized instance state %q", s) + } + return state, reason, nil +} + +// instanceKey is a stable identity for an instance's label set: a sorted +// "k=v\n" join. Used to correlate an instance across polls without hashing. +func instanceKey(labels map[string]string) string { + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(labels[k]) + b.WriteByte('\n') + } + return b.String() +} diff --git a/grafana-alertcheck/internal/gate/parse_state_test.go b/grafana-alertcheck/internal/gate/parse_state_test.go new file mode 100644 index 000000000..7cf336526 --- /dev/null +++ b/grafana-alertcheck/internal/gate/parse_state_test.go @@ -0,0 +1,415 @@ +package gate + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func readFixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("reading fixture %s: %v", name, err) + } + return b +} + +func TestParseState_HappyPaths(t *testing.T) { + cases := []struct { + fixture string + wantRules int + wantInstances int + checkFirst func(t *testing.T, r StateRule) + }{ + { + fixture: "state_one_instance.json", + wantRules: 1, + wantInstances: 1, + checkFirst: func(t *testing.T, r StateRule) { + if r.UID != "rule0000001" { + t.Errorf("UID = %q, want rule0000001", r.UID) + } + if r.Folder != "ExampleTeam" || r.Group != "Example Service - Prod" { + t.Errorf("Folder/Group = %q/%q, want ExampleTeam/Example Service - Prod", r.Folder, r.Group) + } + if r.Health != "ok" || r.State != "inactive" { + t.Errorf("Health/State = %q/%q, want ok/inactive", r.Health, r.State) + } + if r.Interval.Seconds() != 60 { + t.Errorf("Interval = %v, want 60s", r.Interval) + } + if r.IsPaused { + t.Errorf("IsPaused = true, want false") + } + if len(r.Instances) != 1 || r.Instances[0].State != StateNormal { + t.Fatalf("Instances = %+v, want one normal instance", r.Instances) + } + + inst := r.Instances[0] + wantLabels := map[string]string{ + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team", + } + if !maps.Equal(inst.Labels, wantLabels) { + t.Errorf("Labels = %+v, want %+v", inst.Labels, wantLabels) + } + wantActiveAt, err := time.Parse(time.RFC3339, "2026-08-31T08:02:50Z") + if err != nil { + t.Fatalf("test setup: %v", err) + } + if !inst.ActiveAt.Equal(wantActiveAt) { + t.Errorf("ActiveAt = %v, want %v", inst.ActiveAt, wantActiveAt) + } + if inst.Value != "" { + t.Errorf("Value = %q, want empty string", inst.Value) + } + }, + }, + { + fixture: "state_paused.json", + wantRules: 1, + wantInstances: 0, + checkFirst: func(t *testing.T, r StateRule) { + if !r.IsPaused { + t.Errorf("IsPaused = false, want true") + } + if !r.LastEvaluation.IsZero() { + t.Errorf("LastEvaluation = %v, want zero time", r.LastEvaluation) + } + if r.Health != "ok" || r.State != "inactive" { + t.Errorf("Health/State = %q/%q, want ok/inactive", r.Health, r.State) + } + }, + }, + { + fixture: "state_health_error.json", + wantRules: 1, + wantInstances: 1, + checkFirst: func(t *testing.T, r StateRule) { + if r.Health != "error" { + t.Errorf("Health = %q, want error", r.Health) + } + if r.LastError == "" { + t.Errorf("LastError is empty, want a message") + } + if len(r.Instances) != 1 || r.Instances[0].State != StateError { + t.Fatalf("Instances = %+v, want one error instance", r.Instances) + } + }, + }, + { + fixture: "state_health_nodata.json", + wantRules: 1, + wantInstances: 1, + checkFirst: func(t *testing.T, r StateRule) { + if r.Health != "nodata" { + t.Errorf("Health = %q, want nodata", r.Health) + } + if len(r.Instances) != 1 || r.Instances[0].State != StateNodata { + t.Fatalf("Instances = %+v, want one nodata instance", r.Instances) + } + }, + }, + { + fixture: "state_reason_composite.json", + wantRules: 1, + wantInstances: 3, + checkFirst: func(t *testing.T, r StateRule) { + byReason := map[string]Instance{} + for _, inst := range r.Instances { + byReason[inst.Reason] = inst + } + errInst, ok := byReason["Error"] + if !ok || errInst.State != StateNormal { + t.Errorf(`want an instance with State=normal Reason="Error", got %+v`, byReason["Error"]) + } + nodataInst, ok := byReason["NoData"] + if !ok || nodataInst.State != StateNormal { + t.Errorf(`want an instance with State=normal Reason="NoData", got %+v`, byReason["NoData"]) + } + plain, ok := byReason[""] + if !ok || plain.State != StateNormal { + t.Errorf(`want a plain State=normal Reason="" instance, got %+v`, byReason[""]) + } + }, + }, + { + fixture: "state_missing_optional.json", + wantRules: 1, + wantInstances: 0, + checkFirst: func(t *testing.T, r StateRule) { + if r.Instances != nil { + t.Errorf("Instances = %+v, want nil", r.Instances) + } + if r.Totals != nil { + t.Errorf("Totals = %+v, want nil", r.Totals) + } + }, + }, + { + fixture: "state_only_active_instances.json", + wantRules: 1, + wantInstances: 1, + checkFirst: func(t *testing.T, r StateRule) { + if len(r.Instances) != 1 || r.Instances[0].State != StateFiring { + t.Fatalf("Instances = %+v, want one firing instance", r.Instances) + } + if r.Totals["normal"] == 0 { + t.Errorf(`Totals["normal"] = 0, want >0 (this is the §3.2 mismatch the fixture exists to capture)`) + } + }, + }, + } + + for _, c := range cases { + t.Run(c.fixture, func(t *testing.T) { + rules, err := ParseState(readFixture(t, c.fixture)) + if err != nil { + t.Fatalf("ParseState(%s): unexpected error: %v", c.fixture, err) + } + if len(rules) != c.wantRules { + t.Fatalf("ParseState(%s): got %d rules, want %d", c.fixture, len(rules), c.wantRules) + } + if got := len(rules[0].Instances); got != c.wantInstances { + t.Fatalf("ParseState(%s): got %d instances, want %d", c.fixture, got, c.wantInstances) + } + if c.checkFirst != nil { + c.checkFirst(t, rules[0]) + } + }) + } +} + +// TestParseState_MustError is the H1 regression suite: it doesn't just check +// err != nil (a stray comma in a fixture would keep that green forever while +// the actual check regressed) — it asserts the error names the specific +// offending field or value, so a real H1 check going missing fails loudly +// here instead of surviving unnoticed. +func TestParseState_MustError(t *testing.T) { + cases := []struct { + fixture string + wantContains []string + }{ + {"state_missing_health.json", []string{`"health"`}}, + {"state_missing_lasteval.json", []string{`"lastEvaluation"`}}, + {"state_missing_state.json", []string{`"state"`}}, + {"state_missing_interval.json", []string{`"interval"`}}, + {"state_missing_file.json", []string{`"file"`}}, + {"state_missing_name.json", []string{`"name"`}}, + {"state_zerotime_unpaused.json", []string{"zero time", "isPaused"}}, + {"state_unknown_state.json", []string{`"Weird (NoData)"`}}, + } + for _, c := range cases { + t.Run(c.fixture, func(t *testing.T) { + _, err := ParseState(readFixture(t, c.fixture)) + if err == nil { + t.Fatalf("ParseState(%s): expected an error, got none", c.fixture) + } + for _, want := range c.wantContains { + if !strings.Contains(err.Error(), want) { + t.Errorf("ParseState(%s): error %q does not mention %q", c.fixture, err.Error(), want) + } + } + }) + } +} + +func TestParseNormalizeInstanceState(t *testing.T) { + cases := []struct { + in string + wantState State + wantReason string + wantErr bool + }{ + {"Normal", StateNormal, "", false}, + {"Alerting", StateFiring, "", false}, + {"Pending", StatePending, "", false}, + {"NoData", StateNodata, "", false}, + {"Error", StateError, "", false}, + {"Normal (NoData)", StateNormal, "NoData", false}, + {"Normal (Error)", StateNormal, "Error", false}, + {"Normal (MissingSeries)", StateNormal, "MissingSeries", false}, + {"Weird (NoData)", "", "", true}, + {"Weird", "", "", true}, + {"", "", "", true}, + } + for _, c := range cases { + state, reason, err := normalizeInstanceState(c.in) + if c.wantErr { + if err == nil { + t.Errorf("normalizeInstanceState(%q): expected an error, got none", c.in) + } + continue + } + if err != nil { + t.Errorf("normalizeInstanceState(%q): unexpected error: %v", c.in, err) + continue + } + if state != c.wantState || reason != c.wantReason { + t.Errorf("normalizeInstanceState(%q) = (%q, %q), want (%q, %q)", c.in, state, reason, c.wantState, c.wantReason) + } + } +} + +func TestInstanceKey(t *testing.T) { + a := instanceKey(map[string]string{"b": "2", "a": "1"}) + b := instanceKey(map[string]string{"a": "1", "b": "2"}) + if a != b { + t.Errorf("instanceKey order-independence: %q != %q", a, b) + } + if a != "a=1\nb=2\n" { + t.Errorf("instanceKey = %q, want a=1\\nb=2\\n", a) + } + + diff := instanceKey(map[string]string{"a": "1", "b": "3"}) + if a == diff { + t.Errorf("instanceKey should differ when a label value differs") + } + + if instanceKey(nil) != "" { + t.Errorf("instanceKey(nil) = %q, want empty string", instanceKey(nil)) + } +} + +// synthesizeHighCardinalityState builds a state response with a single rule +// holding `alerting` Alerting instances and `normal` Normal instances, by +// cloning the one real instance in state_one_instance.json. It is never +// committed (§3.2, §22.3, §22.6) — the 2446-instance rule this stands in for +// is ~600 KB and exists only to prove the parser and (in later phases) the +// reducer don't choke on real fleet cardinality. +func synthesizeHighCardinalityState(t *testing.T, alerting, normal int) []byte { + t.Helper() + base := readFixture(t, "state_one_instance.json") + + var top map[string]json.RawMessage + if err := json.Unmarshal(base, &top); err != nil { + t.Fatalf("synthesize: %v", err) + } + var data map[string]json.RawMessage + if err := json.Unmarshal(top["data"], &data); err != nil { + t.Fatalf("synthesize: %v", err) + } + var groups []map[string]json.RawMessage + if err := json.Unmarshal(data["groups"], &groups); err != nil { + t.Fatalf("synthesize: %v", err) + } + var rules []map[string]json.RawMessage + if err := json.Unmarshal(groups[0]["rules"], &rules); err != nil { + t.Fatalf("synthesize: %v", err) + } + var alerts []map[string]json.RawMessage + if err := json.Unmarshal(rules[0]["alerts"], &alerts); err != nil { + t.Fatalf("synthesize: %v", err) + } + template := alerts[0] + + newAlerts := make([]map[string]json.RawMessage, 0, alerting+normal) + for i := range alerting { + inst := cloneRawMap(template) + inst["state"] = mustRaw(t, "Alerting") + inst["labels"] = mustRaw(t, map[string]string{"instance": fmt.Sprintf("alerting-%d", i)}) + newAlerts = append(newAlerts, inst) + } + for i := range normal { + inst := cloneRawMap(template) + inst["state"] = mustRaw(t, "Normal") + inst["labels"] = mustRaw(t, map[string]string{"instance": fmt.Sprintf("normal-%d", i)}) + newAlerts = append(newAlerts, inst) + } + + rules[0]["alerts"] = mustRaw(t, newAlerts) + rules[0]["totals"] = mustRaw(t, map[string]int{"alerting": alerting, "normal": normal}) + rules[0]["totalsFiltered"] = rules[0]["totals"] + groups[0]["rules"] = mustRaw(t, rules) + data["groups"] = mustRaw(t, groups) + top["data"] = mustRaw(t, data) + + out, err := json.Marshal(top) + if err != nil { + t.Fatalf("synthesize: %v", err) + } + return out +} + +func cloneRawMap(m map[string]json.RawMessage) map[string]json.RawMessage { + out := make(map[string]json.RawMessage, len(m)) + for k, v := range m { + cp := make(json.RawMessage, len(v)) + copy(cp, v) + out[k] = cp + } + return out +} + +func mustRaw(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return json.RawMessage(b) +} + +func TestParseState_HighCardinality(t *testing.T) { + body := synthesizeHighCardinalityState(t, 445, 2004) + if !bytes.Contains(body, []byte("alerting-0")) { + t.Fatalf("synthesized body missing expected content") + } + + rules, err := ParseState(body) + if err != nil { + t.Fatalf("ParseState: unexpected error: %v", err) + } + if len(rules) != 1 { + t.Fatalf("got %d rules, want 1", len(rules)) + } + r := rules[0] + if len(r.Instances) != 445+2004 { + t.Fatalf("got %d instances, want %d", len(r.Instances), 445+2004) + } + + var firing, normal int + for _, inst := range r.Instances { + switch inst.State { + case StateFiring: + firing++ + case StateNormal: + normal++ + default: + t.Fatalf("unexpected instance state %q", inst.State) + } + } + if firing != 445 || normal != 2004 { + t.Fatalf("got firing=%d normal=%d, want firing=445 normal=2004", firing, normal) + } + + // Each synthesized instance carries a distinct "instance" label; confirm + // Labels actually made it through parsing (not just State) by checking + // instanceKey produces one unique key per instance, with no collisions. + seen := make(map[string]bool, len(r.Instances)) + for _, inst := range r.Instances { + if inst.Labels == nil { + t.Fatalf("instance has nil Labels") + } + k := instanceKey(inst.Labels) + if seen[k] { + t.Fatalf("duplicate instance key %q", k) + } + seen[k] = true + } + if len(seen) != 445+2004 { + t.Fatalf("got %d unique instance keys, want %d", len(seen), 445+2004) + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/README.md b/grafana-alertcheck/internal/gate/testdata/README.md new file mode 100644 index 000000000..fb437b3ae --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/README.md @@ -0,0 +1,91 @@ +# Fixture provenance + +All fixtures are sanitized slices of the real Grafana 13.1.0 payloads captured next to the plan in +`tmp/` (`tmp/state_all.json`, `tmp/ruler_all.json`, `tmp/health.json` — gitignored, never committed). +Renames are consistent across files: the same real folder/rule keeps the same fake identity everywhere +it appears (e.g. `folder0000002`/`rule0000002` is the same real paused rule in both +`state_paused.json` and `ruler_rules.json`). + +An earlier revision embedded these notes as a top-level `_fixture_note` JSON key. That works for the +state-endpoint fixtures (the parser decodes the top level generically, so a stray key is just ignored), +but breaks the ruler-endpoint fixtures: `ParseDefinitions` decodes the whole body directly as +`map[string][]group`, and a `_fixture_note` string value can't unmarshal as `[]group`. Notes now live +here instead, for every fixture, for consistency. + +## State endpoint (`/api/prometheus/grafana/api/v1/rules`) + +- **state_one_instance.json** — real rule (`bfhp23rgt18u8f`, folder `BCM`, "[PROD][ACE] High CPU + Utilization") with exactly one live instance. Renamed to folder `ExampleTeam`/`folder0000001`, rule + `rule0000001`/"Example High CPU Usage", datasource `datasource000001`. Also the template the + high-cardinality test helper (`synthesizeHighCardinalityState`) clones from. +- **state_paused.json** — one of the 3 real `isPaused:true` rules (`ac153fee-...`, folder `Mercury`, + "Rubberbanding"). Renamed to folder `ExampleMetrics`/`folder0000002`, rule + `rule0000002`/"Example Paused Rule". Unmodified: `isPaused:true`, zero `lastEvaluation`, `health:ok`, + `state:inactive`, absent `alerts`/`labels`. +- **state_health_error.json** — real `health:error` rule ("[JD] No Job Proposals", folder + `job-distributor`), highest priority per §22.1. Renamed to folder `ExampleService`/`folder0000003`, + rule `rule0000003`/"Example No Data Source". Unmodified: `health:error`, `lastError` text, the single + `Error` instance. +- **state_health_nodata.json** — real `health:nodata` rule ("ARE test", folder `diegos_playground`). + Renamed to folder `ExamplePlayground`/`folder0000004`, rule `rule0000004`/"Example NoData Rule". + Unmodified: `health:nodata`, the single `NoData` instance. +- **state_reason_composite.json** — composite of two real instances combined under one rule for P1.2a + coverage: a real `"Normal (Error)"` instance (from a Flux-reconciliation rule; 14 of that state exist + in the capture) and a real `"Normal (NoData)"` instance (from a pod-liveness rule; 1091 of that state + exist), plus one plain `"Normal"` instance for contrast. Renamed to folder + `ExampleInfra`/`folder0000005`, rule `rule0000005`/"Example Composite Reasons". +- **state_missing_optional.json** — derived from `state_one_instance.json`: `alerts`, `totals`, + `totalsFiltered` and `labels` all removed. Must parse with `Instances=nil`, `Totals=nil`. +- **state_missing_health.json** — derived from `state_one_instance.json`: the required `health` key + removed. Must be a parse error (H1). +- **state_missing_lasteval.json** — derived from `state_one_instance.json`: the required + `lastEvaluation` key removed. Must be a parse error (H1). +- **state_missing_state.json** — derived from `state_one_instance.json`: the required rule-level + `state` key removed. Must be a parse error (H1). Closes must-error coverage for H1's four required + fields — a review pass found `health`/`lastEvaluation` covered but `state`/`interval` weren't, even + though the code already `req`'d them correctly. +- **state_missing_interval.json** — derived from `state_one_instance.json`: the required group-level + `interval` key removed. Must be a parse error (H1); same review-pass gap as above. +- **state_missing_file.json** / **state_missing_name.json** — derived from `state_one_instance.json`: + the group-level `file`/`name` keys removed respectively. Not part of H1's four (those are `health`, + `state`, `lastEvaluation`, `interval`), but the code treats group identity as strict too, and the same + review pass flagged the gap — closed rather than deferred to a later §22 sweep since the fixture is + the same 10-line edit. +- **state_zerotime_unpaused.json** — derived from `state_one_instance.json`: `lastEvaluation` set to + the zero time while `isPaused` stays `false`. Must be a parse error (§2.3). +- **state_unknown_state.json** — derived from `state_one_instance.json`: the instance state hand-edited + to `"Weird (NoData)"`, a syntactically valid composite whose base isn't in the 5-value allowlist. Must + be a parse error (P1.2a). +- **state_only_active_instances.json** — derived from a real rule that genuinely had 1 `Alerting` + 22 + `Normal` instances (`totals: {alerting:1, normal:22}`, rule `dfhp1t5pkosu8f`, folder `BCM`). `alerts[]` + trimmed to the single `Alerting` instance only, while `totals` is left **unchanged** — reproducing the + §3.2 violation shape (instance list says "only active" while totals disagrees). Renamed to folder + `ExampleTeam`/`folder0000001`, rule `rule0000006`. `ParseState` itself parses this fine; the §3.2 + verification lives in a later phase (P5/P9). + +## Ruler endpoint (`/api/ruler/grafana/api/v1/rules`) + +- **ruler_rules.json** — contains: + - The real true 2-way title collision: namespace `CRE-BCM-Prod-Zone-A`, group `Gateway`, identical + folder+group+title, distinct UIDs (`ffvabtvvbozcwf`/`efvabtwbxlvk0b`) — renamed to namespace + `Example-Zone-A`, rules `rule0000006a`/`rule0000006b`, both titled "Example No Gateways Available". + Folder/Group/Title alone does **not** disambiguate this pair (§17, §22.2). + - The 3 real `is_paused:true` rules, renamed to `rule0000002`/`rule0000007`/`rule0000008`. + `rule0000002` intentionally shares its identity (`folder0000002`) with `state_paused.json`. + - A real `for:1d` rule (`afs438kjd4v7kd` → `rule0000009`). + - **`rule0000010` is DERIVED**: no `for:1w` rule exists anywhere in the capture (verified). Built by + copying the `for:1d` rule and changing `for` to `1w` and its identity, to exercise the `w` unit. +- **ruler_datasource_managed.json** — **DERIVED**, no datasource-managed rule exists in the capture + (verified: 0 rules lack a `grafana_alert` block). Hand-built minimal shape: a rule object with no + `grafana_alert` key at all and an `alert` field carrying its Prometheus-format name — exactly how + Grafana represents a datasource-managed (native Prometheus-format) alerting rule. `ParseDefinitions` + must classify it as `KindDatasourceManaged`, parse `Title` from `alert`, and leave `UID` empty (this + shape has no uid at all — inventing one would be inventing shape) without rejecting the rule + (rejection is P3's job, only for rules a user actually named). +- **ruler_recording.json** — **DERIVED**, no recording rule exists in the capture (verified: 0 rules + carry `grafana_alert.record`). Hand-built: a `grafana_alert` block with a `record` sub-object but + deliberately *without* `no_data_state`/`exec_err_state`/`is_paused`/`intervalSeconds`/`namespace_uid` + — those are alerting-only concepts a recording rule may not carry, and since none exist in the + capture that shape is unverified either way. `ParseDefinitions` must classify it as `KindRecording` + and must not require those fields for this Kind (requiring them bricks `ParseDefinitions` for every + named rule in the same response over one recording rule elsewhere in the fleet). diff --git a/grafana-alertcheck/internal/gate/testdata/ruler_datasource_managed.json b/grafana-alertcheck/internal/gate/testdata/ruler_datasource_managed.json new file mode 100644 index 000000000..a909f7754 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/ruler_datasource_managed.json @@ -0,0 +1,21 @@ +{ + "ExampleMetrics": [ + { + "name": "Datasource Managed Group", + "interval": "1m", + "rules": [ + { + "alert": "ExampleTargetDown", + "expr": "up == 0", + "for": "5m", + "labels": { + "severity": "warning" + }, + "annotations": { + "summary": "target down" + } + } + ] + } + ] +} diff --git a/grafana-alertcheck/internal/gate/testdata/ruler_recording.json b/grafana-alertcheck/internal/gate/testdata/ruler_recording.json new file mode 100644 index 000000000..07881626c --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/ruler_recording.json @@ -0,0 +1,44 @@ +{ + "ExampleMetrics": [ + { + "name": "Recording Group", + "interval": "1m", + "rules": [ + { + "expr": "", + "for": "0s", + "labels": {}, + "annotations": {}, + "grafana_alert": { + "title": "example:recorded_metric:rate5m", + "condition": "", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "datasource000010", + "model": { + "expr": "rate(example_metric_total[5m])", + "refId": "A" + } + } + ], + "record": { + "metric": "example:recorded_metric:rate5m", + "from": "A" + }, + "updated": "2026-08-01T00:00:00Z", + "version": 1, + "uid": "rule0000011", + "rule_group": "Recording Group", + "guid": "example-guid-0011" + } + } + ] + } + ] +} diff --git a/grafana-alertcheck/internal/gate/testdata/ruler_rules.json b/grafana-alertcheck/internal/gate/testdata/ruler_rules.json new file mode 100644 index 000000000..4eceeadce --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/ruler_rules.json @@ -0,0 +1,332 @@ +{ + "Example-Zone-A": [ + { + "name": "Gateway", + "interval": "1m", + "rules": [ + { + "expr": "", + "for": "5m", + "keep_firing_for": "0s", + "labels": { + "env": "production", + "severity": "critical", + "team": "example-team", + "zone": "zone-a" + }, + "annotations": { + "description": "Node(s) have no gateways configured.", + "runbook_url": "https://example.com/runbook", + "summary": "No gateways available for 5m" + }, + "grafana_alert": { + "title": "Example No Gateways Available", + "condition": "C", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "datasource000006", + "model": { + "expr": "sum(example_no_gateways_available_count[5m])", + "refId": "A" + } + } + ], + "updated": "2026-08-20T09:54:36Z", + "intervalSeconds": 60, + "version": 16, + "uid": "rule0000006a", + "namespace_uid": "folder0000006", + "rule_group": "Gateway", + "no_data_state": "OK", + "exec_err_state": "OK", + "is_paused": false, + "guid": "example-guid-0006a" + } + }, + { + "expr": "", + "for": "5m", + "keep_firing_for": "0s", + "labels": { + "env": "production", + "severity": "critical", + "team": "example-team", + "zone": "zone-a" + }, + "annotations": { + "description": "Node(s) have no gateways configured.", + "runbook_url": "https://example.com/runbook", + "summary": "No gateways available for 5m" + }, + "grafana_alert": { + "title": "Example No Gateways Available", + "condition": "C", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "datasource000006", + "model": { + "expr": "sum(example_no_gateways_available_count[5m])", + "refId": "A" + } + } + ], + "updated": "2026-08-20T09:54:40Z", + "intervalSeconds": 60, + "version": 3, + "uid": "rule0000006b", + "namespace_uid": "folder0000006", + "rule_group": "Gateway", + "no_data_state": "OK", + "exec_err_state": "OK", + "is_paused": false, + "guid": "example-guid-0006b" + } + } + ] + }, + { + "name": "EVM Capabilities", + "interval": "1m", + "rules": [ + { + "expr": "", + "for": "1d", + "keep_firing_for": "0s", + "labels": { + "env": "production", + "severity": "warning", + "team": "example-team" + }, + "annotations": { + "description": "Chain has an elevated failure ratio.", + "summary": "Failure ratio > 10% for 1d" + }, + "grafana_alert": { + "title": "Example Failure Ratio Above 10 Percent", + "condition": "C", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "datasource000009", + "model": { + "expr": "sum(example_failure_ratio)", + "refId": "A" + } + } + ], + "updated": "2026-08-10T17:24:04Z", + "intervalSeconds": 60, + "version": 5, + "uid": "rule0000009", + "namespace_uid": "folder0000006", + "rule_group": "EVM Capabilities", + "no_data_state": "OK", + "exec_err_state": "OK", + "is_paused": false, + "guid": "example-guid-0009" + } + }, + { + "expr": "", + "for": "1w", + "keep_firing_for": "0s", + "labels": { + "env": "production", + "severity": "warning", + "team": "example-team" + }, + "annotations": { + "description": "DERIVED fixture: no real for:1w rule exists in the capture — copied from the for:1d rule above with 'for' changed to exercise the w unit.", + "summary": "Failure ratio elevated for 1w" + }, + "grafana_alert": { + "title": "Example Failure Ratio Above 10 Percent Weekly", + "condition": "C", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "datasource000009", + "model": { + "expr": "sum(example_failure_ratio)", + "refId": "A" + } + } + ], + "updated": "2026-08-10T17:24:04Z", + "intervalSeconds": 60, + "version": 1, + "uid": "rule0000010", + "namespace_uid": "folder0000006", + "rule_group": "EVM Capabilities", + "no_data_state": "OK", + "exec_err_state": "OK", + "is_paused": false, + "guid": "example-guid-0010" + } + } + ] + } + ], + "ExampleObservability": [ + { + "name": "Example Auth Production", + "interval": "1m", + "rules": [ + { + "expr": "", + "for": "5m", + "keep_firing_for": "0s", + "labels": { + "env": "production" + }, + "annotations": {}, + "grafana_alert": { + "title": "example_workflow_paused_rule", + "condition": "C", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "datasource000007", + "model": { + "expr": "sum(example_auth_client_requests)", + "refId": "A" + } + } + ], + "updated": "2026-08-01T00:00:00Z", + "intervalSeconds": 60, + "version": 1, + "uid": "rule0000007", + "namespace_uid": "folder0000007", + "rule_group": "Example Auth Production", + "no_data_state": "Alerting", + "exec_err_state": "Error", + "is_paused": true, + "guid": "example-guid-0007" + } + } + ] + } + ], + "ExampleFeeds": [ + { + "name": "Example Feeds Annotations", + "interval": "30s", + "rules": [ + { + "expr": "", + "for": "1m", + "keep_firing_for": "2m", + "labels": { + "env": "production", + "severity": "critical", + "team": "example-feeds-pm" + }, + "annotations": { + "summary": "[TEST ONLY] example depeg alert" + }, + "grafana_alert": { + "title": "TEMP - Example depeg alert", + "condition": "C", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "datasource000008", + "model": { + "expr": "avg(example_feed_answer)", + "refId": "A" + } + } + ], + "updated": "2026-08-01T00:00:00Z", + "intervalSeconds": 30, + "version": 1, + "uid": "rule0000008", + "namespace_uid": "folder0000008", + "rule_group": "Example Feeds Annotations", + "no_data_state": "NoData", + "exec_err_state": "Error", + "is_paused": true, + "guid": "example-guid-0008" + } + } + ] + } + ], + "ExampleMetrics": [ + { + "name": "Example Paused Rule", + "interval": "5m", + "rules": [ + { + "expr": "", + "for": "5m", + "keep_firing_for": "0s", + "labels": {}, + "annotations": {}, + "grafana_alert": { + "title": "Example Paused Rule", + "condition": "C", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 600, + "to": 0 + }, + "datasourceUid": "datasource000002", + "model": { + "expr": "example_metric{env=\"production\"}", + "refId": "A" + } + } + ], + "updated": "2026-08-01T00:00:00Z", + "intervalSeconds": 300, + "version": 1, + "uid": "rule0000002", + "namespace_uid": "folder0000002", + "rule_group": "Example Paused Rule", + "no_data_state": "NoData", + "exec_err_state": "Error", + "is_paused": true, + "guid": "example-guid-0002" + } + } + ] + } + ] +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_health_error.json b/grafana-alertcheck/internal/gate/testdata/state_health_error.json new file mode 100644 index 000000000..92a50fc83 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_health_error.json @@ -0,0 +1,68 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "ExampleService", + "file": "ExampleService", + "folderUid": "folder0000003", + "rules": [ + { + "state": "inactive", + "name": "Example No Data Source", + "query": "sum(increase(example_requests_total{env=\"staging\", method=\"Example\"}[30d]))", + "queriedDatasourceUIDs": [ + "datasource000003" + ], + "duration": 300, + "annotations": { + "description": "Just a test", + "summary": "Just a test" + }, + "alerts": [ + { + "labels": { + "alertname": "Example No Data Source", + "grafana_folder": "ExampleService", + "product": "example-service" + }, + "annotations": { + "Error": "failed to build query 'A': data source not found", + "description": "Just a test", + "summary": "Just a test" + }, + "state": "Error", + "activeAt": "2026-08-20T15:45:00Z", + "value": "" + } + ], + "totals": { + "error": 1 + }, + "totalsFiltered": { + "error": 1 + }, + "uid": "rule0000003", + "folderUid": "folder0000003", + "labels": { + "product": "example-service" + }, + "health": "error", + "lastError": "failed to build query 'A': data source not found", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:15:00Z", + "evaluationTime": 0.00109723, + "isPaused": false + } + ], + "totals": { + "error": 1, + "inactive": 1 + }, + "interval": 300, + "lastEvaluation": "2026-08-31T09:15:00Z", + "evaluationTime": 0.00109723 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_health_nodata.json b/grafana-alertcheck/internal/gate/testdata/state_health_nodata.json new file mode 100644 index 000000000..fc3b6949c --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_health_nodata.json @@ -0,0 +1,62 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "example_group", + "file": "ExamplePlayground", + "folderUid": "folder0000004", + "rules": [ + { + "state": "inactive", + "name": "Example NoData Rule", + "query": "sum(example_satisfied_slo == 1)", + "queriedDatasourceUIDs": [ + "datasource000004" + ], + "duration": 300, + "annotations": { + "description": "{{ range $value := query \"sum by (id) (example_satisfied_slo == 1)\" }}\n - {{ $value.Labels.id }}\n{{ end }}" + }, + "alerts": [ + { + "labels": { + "alertname": "Example NoData Rule", + "datasource_uid": "datasource000004", + "grafana_folder": "ExamplePlayground", + "ref_id": "A" + }, + "annotations": { + "description": "" + }, + "state": "NoData", + "activeAt": "2026-08-24T09:06:30Z", + "value": "" + } + ], + "totals": { + "nodata": 1 + }, + "totalsFiltered": { + "nodata": 1 + }, + "uid": "rule0000004", + "folderUid": "folder0000004", + "health": "nodata", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:30Z", + "evaluationTime": 0.34196819, + "isPaused": false + } + ], + "totals": { + "inactive": 1, + "nodata": 1 + }, + "interval": 300, + "lastEvaluation": "2026-08-31T09:16:30Z", + "evaluationTime": 0.34196819 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_missing_file.json b/grafana-alertcheck/internal/gate/testdata/state_missing_file.json new file mode 100644 index 000000000..bc7beb4c7 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_missing_file.json @@ -0,0 +1,73 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Service - Prod", + "folderUid": "folder0000001", + "rules": [ + { + "state": "inactive", + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "alerts": [ + { + "labels": { + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "example-app-instance is using 0.0075 of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "example-app-instance CPU utilization exceeded 90% of limit" + }, + "state": "Normal", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 1 + }, + "totalsFiltered": { + "normal": 1 + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "labels": { + "env": "prod", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "health": "ok", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:50Z", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_missing_health.json b/grafana-alertcheck/internal/gate/testdata/state_missing_health.json new file mode 100644 index 000000000..d450a32f6 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_missing_health.json @@ -0,0 +1,73 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Service - Prod", + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "state": "inactive", + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "alerts": [ + { + "labels": { + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "example-app-instance is using 0.0075 of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "example-app-instance CPU utilization exceeded 90% of limit" + }, + "state": "Normal", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 1 + }, + "totalsFiltered": { + "normal": 1 + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "labels": { + "env": "prod", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:50Z", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_missing_interval.json b/grafana-alertcheck/internal/gate/testdata/state_missing_interval.json new file mode 100644 index 000000000..4bc7dec18 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_missing_interval.json @@ -0,0 +1,73 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Service - Prod", + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "state": "inactive", + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "alerts": [ + { + "labels": { + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "example-app-instance is using 0.0075 of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "example-app-instance CPU utilization exceeded 90% of limit" + }, + "state": "Normal", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 1 + }, + "totalsFiltered": { + "normal": 1 + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "labels": { + "env": "prod", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "health": "ok", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:50Z", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_missing_lasteval.json b/grafana-alertcheck/internal/gate/testdata/state_missing_lasteval.json new file mode 100644 index 000000000..4a2e8fcb9 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_missing_lasteval.json @@ -0,0 +1,73 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Service - Prod", + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "state": "inactive", + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "alerts": [ + { + "labels": { + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "example-app-instance is using 0.0075 of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "example-app-instance CPU utilization exceeded 90% of limit" + }, + "state": "Normal", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 1 + }, + "totalsFiltered": { + "normal": 1 + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "labels": { + "env": "prod", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "health": "ok", + "type": "alerting", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_missing_name.json b/grafana-alertcheck/internal/gate/testdata/state_missing_name.json new file mode 100644 index 000000000..eb5e8eb2a --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_missing_name.json @@ -0,0 +1,73 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "state": "inactive", + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "alerts": [ + { + "labels": { + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "example-app-instance is using 0.0075 of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "example-app-instance CPU utilization exceeded 90% of limit" + }, + "state": "Normal", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 1 + }, + "totalsFiltered": { + "normal": 1 + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "labels": { + "env": "prod", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "health": "ok", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:50Z", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_missing_optional.json b/grafana-alertcheck/internal/gate/testdata/state_missing_optional.json new file mode 100644 index 000000000..9ad5c87f5 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_missing_optional.json @@ -0,0 +1,41 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Service - Prod", + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "state": "inactive", + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "health": "ok", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:50Z", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_missing_state.json b/grafana-alertcheck/internal/gate/testdata/state_missing_state.json new file mode 100644 index 000000000..8db78b789 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_missing_state.json @@ -0,0 +1,73 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Service - Prod", + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "alerts": [ + { + "labels": { + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "example-app-instance is using 0.0075 of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "example-app-instance CPU utilization exceeded 90% of limit" + }, + "state": "Normal", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 1 + }, + "totalsFiltered": { + "normal": 1 + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "labels": { + "env": "prod", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "health": "ok", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:50Z", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_one_instance.json b/grafana-alertcheck/internal/gate/testdata/state_one_instance.json new file mode 100644 index 000000000..09afe0fbc --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_one_instance.json @@ -0,0 +1,74 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Service - Prod", + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "state": "inactive", + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "alerts": [ + { + "labels": { + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "example-app-instance is using 0.0075 of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "example-app-instance CPU utilization exceeded 90% of limit" + }, + "state": "Normal", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 1 + }, + "totalsFiltered": { + "normal": 1 + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "labels": { + "env": "prod", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "health": "ok", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:50Z", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_only_active_instances.json b/grafana-alertcheck/internal/gate/testdata/state_only_active_instances.json new file mode 100644 index 000000000..ac39c69b0 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_only_active_instances.json @@ -0,0 +1,79 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "example-reporting-stage", + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "state": "firing", + "name": "Example Missing Pipeline Events", + "query": "max by (pipeline, event_name) (example_oldest_missing_event_age_ms{cluster=\"example-cluster\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "The oldest un-materialized event has been missing for more than 30 minutes.", + "runbook_url": "https://example.com/runbook", + "summary": "Pipeline event stuck" + }, + "activeAt": "2026-08-31T04:38:10Z", + "alerts": [ + { + "labels": { + "alertname": "Example Missing Pipeline Events", + "env": "stage", + "event_name": "PolicyConfigured", + "grafana_folder": "ExampleTeam", + "pipeline": "policy_configured", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "The oldest un-materialized event for pipeline policy_configured has been missing for more than 30 minutes.", + "runbook_url": "https://example.com/runbook", + "summary": "Pipeline policy_configured has a PolicyConfigured event stuck for 18747998ms" + }, + "state": "Alerting", + "activeAt": "2026-08-31T04:38:10Z", + "value": "1e+00" + } + ], + "totals": { + "alerting": 1, + "normal": 22 + }, + "totalsFiltered": { + "alerting": 1, + "normal": 22 + }, + "uid": "rule0000006", + "folderUid": "folder0000001", + "labels": { + "env": "stage", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "health": "ok", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:10Z", + "evaluationTime": 0.016299343, + "isPaused": false + } + ], + "totals": { + "firing": 1, + "inactive": 9 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:16:10Z", + "evaluationTime": 0.003291541 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_paused.json b/grafana-alertcheck/internal/gate/testdata/state_paused.json new file mode 100644 index 000000000..e44d367f5 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_paused.json @@ -0,0 +1,40 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Paused Rule", + "file": "ExampleMetrics", + "folderUid": "folder0000002", + "rules": [ + { + "state": "inactive", + "name": "Example Paused Rule", + "query": "example_metric{env=\"production\"}", + "queriedDatasourceUIDs": [ + "datasource000002" + ], + "duration": 300, + "annotations": { + "__dashboardUid__": "example-dashboard-uid", + "__panelId__": "1" + }, + "uid": "rule0000002", + "folderUid": "folder0000002", + "health": "ok", + "type": "alerting", + "lastEvaluation": "0001-01-01T00:00:00Z", + "evaluationTime": 0, + "isPaused": true + } + ], + "totals": { + "inactive": 1 + }, + "interval": 300, + "lastEvaluation": "0001-01-01T00:00:00Z", + "evaluationTime": 0 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_reason_composite.json b/grafana-alertcheck/internal/gate/testdata/state_reason_composite.json new file mode 100644 index 000000000..b28a6d50d --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_reason_composite.json @@ -0,0 +1,99 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "example-infra", + "file": "ExampleInfra", + "folderUid": "folder0000005", + "rules": [ + { + "state": "inactive", + "name": "Example Composite Reasons", + "query": "sum(increase(example_reconcile_total{result=\"error\"}[1m])) by (cluster)", + "queriedDatasourceUIDs": [ + "datasource000005" + ], + "duration": 300, + "annotations": { + "summary": "{{ cluster }} is having reconciliation issues." + }, + "alerts": [ + { + "labels": { + "alertname": "Example Composite Reasons", + "grafana_folder": "ExampleInfra", + "cluster": "example-cluster-a", + "severity": "error", + "team": "example-infra" + }, + "annotations": { + "Error": "failed to build query 'B': data source not found", + "summary": "example-cluster-a is having reconciliation issues." + }, + "state": "Normal (Error)", + "activeAt": "2025-08-07T11:48:10Z", + "value": "" + }, + { + "labels": { + "alertname": "Example Composite Reasons", + "grafana_folder": "ExampleInfra", + "cluster": "example-cluster-b", + "severity": "warning", + "team": "example-infra" + }, + "annotations": { + "Error": "unexpected response with status code 429", + "description": "example-cluster-b query returned no data recently.", + "summary": "example-cluster-b is not reporting" + }, + "state": "Normal (NoData)", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + }, + { + "labels": { + "alertname": "Example Composite Reasons", + "grafana_folder": "ExampleInfra", + "cluster": "example-cluster-c", + "severity": "warning", + "team": "example-infra" + }, + "annotations": { + "summary": "example-cluster-c is healthy" + }, + "state": "Normal", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 3 + }, + "totalsFiltered": { + "normal": 3 + }, + "uid": "rule0000005", + "folderUid": "folder0000005", + "labels": { + "severity": "error", + "team": "example-infra" + }, + "health": "ok", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:10Z", + "evaluationTime": 0.001283254, + "isPaused": false + } + ], + "totals": { + "inactive": 1 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:16:10Z", + "evaluationTime": 0.001283254 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_unknown_state.json b/grafana-alertcheck/internal/gate/testdata/state_unknown_state.json new file mode 100644 index 000000000..4cda28ca3 --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_unknown_state.json @@ -0,0 +1,74 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Service - Prod", + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "state": "inactive", + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "alerts": [ + { + "labels": { + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "example-app-instance is using 0.0075 of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "example-app-instance CPU utilization exceeded 90% of limit" + }, + "state": "Weird (NoData)", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 1 + }, + "totalsFiltered": { + "normal": 1 + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "labels": { + "env": "prod", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "health": "ok", + "type": "alerting", + "lastEvaluation": "2026-08-31T09:16:50Z", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +} diff --git a/grafana-alertcheck/internal/gate/testdata/state_zerotime_unpaused.json b/grafana-alertcheck/internal/gate/testdata/state_zerotime_unpaused.json new file mode 100644 index 000000000..b9a0fdd1a --- /dev/null +++ b/grafana-alertcheck/internal/gate/testdata/state_zerotime_unpaused.json @@ -0,0 +1,74 @@ +{ + "status": "success", + "data": { + "groups": [ + { + "name": "Example Service - Prod", + "file": "ExampleTeam", + "folderUid": "folder0000001", + "rules": [ + { + "state": "inactive", + "name": "Example High CPU Usage", + "query": "max by (app_instance) (example_cpu_utilization_ratio{env=\"prod\", app=\"example-app\"})", + "queriedDatasourceUIDs": [ + "datasource000001" + ], + "duration": 300, + "annotations": { + "description": "{{ index $labels \"app_instance\" }} is using {{ index $values \"B\" }} of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "{{ index $labels \"app_instance\" }} CPU utilization exceeded 90% of limit" + }, + "alerts": [ + { + "labels": { + "alertname": "Example High CPU Usage", + "env": "prod", + "grafana_folder": "ExampleTeam", + "app_instance": "example-app-instance", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "annotations": { + "description": "example-app-instance is using 0.0075 of its CPU limit.", + "runbook_url": "https://example.com/runbook", + "summary": "example-app-instance CPU utilization exceeded 90% of limit" + }, + "state": "Normal", + "activeAt": "2026-08-31T08:02:50Z", + "value": "" + } + ], + "totals": { + "normal": 1 + }, + "totalsFiltered": { + "normal": 1 + }, + "uid": "rule0000001", + "folderUid": "folder0000001", + "labels": { + "env": "prod", + "service": "example-svc", + "severity": "critical", + "team": "example-team" + }, + "health": "ok", + "type": "alerting", + "lastEvaluation": "0001-01-01T00:00:00Z", + "evaluationTime": 0.003748832, + "isPaused": false + } + ], + "totals": { + "inactive": 14 + }, + "interval": 60, + "lastEvaluation": "2026-08-31T09:15:50Z", + "evaluationTime": 0.005293504 + } + ] + } +}