From 858e5709c9f535a6d4602f4e192eaeb0b4aa601a Mon Sep 17 00:00:00 2001 From: Bartek Tofel Date: Mon, 31 Aug 2026 14:05:48 +0200 Subject: [PATCH 1/2] chore: implement phase 3 Add Resolve() for alert name resolution (uid:/Title/Folder/Title/ Folder/Group/Title forms, UID collapse, no-match suggestions) and the grafana-alertcheck CLI's list subcommand, the first runnable piece of the gate. Incorporates review fixes: reject empty path segments in classifyForm, guard uid: against an empty suffix, scope the no-match rule count and suggestions to supported rule kinds only, and exit 0 on -h/--help. --- .../cmd/grafana-alertcheck/env.go | 22 ++ .../cmd/grafana-alertcheck/list.go | 93 ++++++ .../cmd/grafana-alertcheck/list_test.go | 102 +++++++ .../cmd/grafana-alertcheck/main.go | 43 ++- .../cmd/grafana-alertcheck/main_test.go | 60 ++++ grafana-alertcheck/internal/gate/resolve.go | 209 ++++++++++++++ .../internal/gate/resolve_test.go | 273 ++++++++++++++++++ 7 files changed, 800 insertions(+), 2 deletions(-) create mode 100644 grafana-alertcheck/cmd/grafana-alertcheck/env.go create mode 100644 grafana-alertcheck/cmd/grafana-alertcheck/list.go create mode 100644 grafana-alertcheck/cmd/grafana-alertcheck/list_test.go create mode 100644 grafana-alertcheck/cmd/grafana-alertcheck/main_test.go create mode 100644 grafana-alertcheck/internal/gate/resolve.go create mode 100644 grafana-alertcheck/internal/gate/resolve_test.go diff --git a/grafana-alertcheck/cmd/grafana-alertcheck/env.go b/grafana-alertcheck/cmd/grafana-alertcheck/env.go new file mode 100644 index 000000000..e5702a6d1 --- /dev/null +++ b/grafana-alertcheck/cmd/grafana-alertcheck/env.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "os" +) + +// grafanaEnv reads the connection details from the environment only, never +// from a flag — a flag value lands in the process argv and in CI logs, and +// the token must never be logged or otherwise surface in an error string +// (§20.2). +func grafanaEnv() (url, token string, err error) { + url = os.Getenv("GRAFANA_URL") + if url == "" { + return "", "", fmt.Errorf("GRAFANA_URL is not set") + } + token = os.Getenv("GRAFANA_TOKEN") + if token == "" { + return "", "", fmt.Errorf("GRAFANA_TOKEN is not set") + } + return url, token, nil +} diff --git a/grafana-alertcheck/cmd/grafana-alertcheck/list.go b/grafana-alertcheck/cmd/grafana-alertcheck/list.go new file mode 100644 index 000000000..687e5dd9f --- /dev/null +++ b/grafana-alertcheck/cmd/grafana-alertcheck/list.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "fmt" + "io" + "sort" + "text/tabwriter" + + "github.com/smartcontractkit/chainlink-testing-framework/grafana-alertcheck/internal/gate" +) + +// runList reads every rule definition from the ruler endpoint and prints one +// line per rule: its kind, its Folder/Group/Title, and its uid. This is what +// makes the gate runnable end to end before any coverage logic exists (§9 +// rule 4) — it validates auth, the ruler parse, and the shapes Resolve +// matches against, all against a real Grafana. It is also the "did you mean" +// surface §17.2's no-match error points operators at. +func runList(args []string, stdout, stderr io.Writer) int { + if len(args) != 0 { + fmt.Fprintf(stderr, "list takes no arguments, got %v\n", args) + return 2 + } + + url, token, err := grafanaEnv() + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + + // context.Background(), no outer deadline: httpSource bounds every single + // attempt with its http.Client's 30s Timeout (source.go) and gives up + // after maxSequentialFailures consecutive transport errors, so this call + // always terminates. It can still take minutes end-to-end under repeated + // transient failures (5 retries * up to 30s backoff each, per call) — an + // acceptable wait for an interactive `list`, not for `watch`/`check`, + // which get their own deadlines from `--until`/`to` in P10. + src := gate.NewHTTPSource(url, token, gate.SystemClock{}) + version, err := src.Version(context.Background()) + if err != nil { + fmt.Fprintf(stderr, "checking grafana version: %v\n", err) + return 2 + } + if err := gate.CheckGrafanaVersion(version); err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + + defs, err := src.Definitions(context.Background()) + if err != nil { + fmt.Fprintf(stderr, "reading rule definitions: %v\n", err) + return 2 + } + + sort.Slice(defs, func(i, j int) bool { + if defs[i].Folder != defs[j].Folder { + return defs[i].Folder < defs[j].Folder + } + if defs[i].Group != defs[j].Group { + return defs[i].Group < defs[j].Group + } + return defs[i].Title < defs[j].Title + }) + + tw := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "KIND\tFOLDER\tGROUP\tTITLE\tUID") + for _, d := range defs { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", kindLabel(d.Kind), d.Folder, d.Group, d.Title, uidOrDash(d.UID)) + } + if err := tw.Flush(); err != nil { + fmt.Fprintf(stderr, "writing output: %v\n", err) + return 2 + } + return 0 +} + +func kindLabel(k gate.RuleKind) string { + switch k { + case gate.KindDatasourceManaged: + return "datasource-managed" + case gate.KindRecording: + return "recording" + default: + return "grafana-managed" + } +} + +func uidOrDash(uid string) string { + if uid == "" { + return "-" + } + return uid +} diff --git a/grafana-alertcheck/cmd/grafana-alertcheck/list_test.go b/grafana-alertcheck/cmd/grafana-alertcheck/list_test.go new file mode 100644 index 000000000..119326c2c --- /dev/null +++ b/grafana-alertcheck/cmd/grafana-alertcheck/list_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const rulerBody = `{ + "Example-Zone-A": [ + { + "name": "Gateway", + "rules": [ + { + "for": "5m", + "grafana_alert": { + "title": "Example No Gateways Available", + "uid": "rule0000006a", + "namespace_uid": "folder0000006", + "intervalSeconds": 60, + "no_data_state": "OK", + "exec_err_state": "OK", + "is_paused": false + } + } + ] + } + ] +}` + +func healthBody(version string) string { + return fmt.Sprintf(`{"database":"ok","version":%q,"commit":"abc123"}`, version) +} + +func grafanaTestServer(t *testing.T, version string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/health": + _, _ = w.Write([]byte(healthBody(version))) + case "/api/ruler/grafana/api/v1/rules": + _, _ = w.Write([]byte(rulerBody)) + default: + t.Errorf("unexpected path %q", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestRunList_HappyPath(t *testing.T) { + srv := grafanaTestServer(t, "13.1.0") + t.Setenv("GRAFANA_URL", srv.URL) + t.Setenv("GRAFANA_TOKEN", "test-token") + + var stdout, stderr bytes.Buffer + code := run([]string{"list"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0; stderr = %q", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "rule0000006a") { + t.Errorf("stdout = %q, want it to list rule0000006a", out) + } + if !strings.Contains(out, "Example No Gateways Available") { + t.Errorf("stdout = %q, want it to list the rule title", out) + } + if !strings.Contains(out, "grafana-managed") { + t.Errorf("stdout = %q, want it to name the rule kind", out) + } +} + +func TestRunList_UnsupportedVersion(t *testing.T) { + srv := grafanaTestServer(t, "12.5.0") + t.Setenv("GRAFANA_URL", srv.URL) + t.Setenv("GRAFANA_TOKEN", "test-token") + + var stdout, stderr bytes.Buffer + code := run([]string{"list"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "12.5.0") { + t.Fatalf("stderr = %q, want it to name the unsupported version", stderr.String()) + } +} + +func TestRunList_RejectsArgs(t *testing.T) { + t.Setenv("GRAFANA_URL", "http://example.invalid") + t.Setenv("GRAFANA_TOKEN", "test-token") + + var stdout, stderr bytes.Buffer + code := run([]string{"list", "extra"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } +} diff --git a/grafana-alertcheck/cmd/grafana-alertcheck/main.go b/grafana-alertcheck/cmd/grafana-alertcheck/main.go index 150d617d4..d7968f5a8 100644 --- a/grafana-alertcheck/cmd/grafana-alertcheck/main.go +++ b/grafana-alertcheck/cmd/grafana-alertcheck/main.go @@ -1,7 +1,46 @@ +// Command grafana-alertcheck is the CLI entry point for the gate. P3 wires +// only the `list` subcommand — enough to validate auth, the ruler parse, and +// resolution against a real Grafana before any coverage logic exists (§9 rule +// 4, "reach runnable at PR 5"). P10 extends this file with `watch` and +// `check`. package main -import "os" +import ( + "fmt" + "io" + "os" +) func main() { - os.Exit(2) + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +const usage = "usage: grafana-alertcheck " + +// run is the whole of main's testable surface: parse the subcommand, dispatch, +// return the process exit code. Exit codes below 2 (pass/violations) belong to +// `check` alone (§20.3, P10); every failure reachable from here — a missing +// subcommand, a bad flag, a transport or auth failure — is a could-not-check +// condition and maps to 2, never to 0 or 1 (H7). +// +// Requested help (-h/--help) is not a failure — it is the one exception to +// that rule. Convention (and every stdlib flag.FlagSet default) is exit 0 to +// stdout for help the caller asked for, reserving 2/stderr for help printed +// *because* something else went wrong (no subcommand, an unknown one). +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, usage) + return 2 + } + + switch args[0] { + case "list": + return runList(args[1:], stdout, stderr) + case "-h", "-help", "--help": + fmt.Fprintln(stdout, usage) + return 0 + default: + fmt.Fprintf(stderr, "unknown subcommand %q\n", args[0]) + return 2 + } } diff --git a/grafana-alertcheck/cmd/grafana-alertcheck/main_test.go b/grafana-alertcheck/cmd/grafana-alertcheck/main_test.go new file mode 100644 index 000000000..1d7906674 --- /dev/null +++ b/grafana-alertcheck/cmd/grafana-alertcheck/main_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestRun_NoArgs(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(nil, &stdout, &stderr) + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "usage") { + t.Fatalf("stderr = %q, want a usage message", stderr.String()) + } +} + +func TestRun_Help(t *testing.T) { + for _, flag := range []string{"-h", "-help", "--help"} { + t.Run(flag, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{flag}, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, want 0 (requested help is not a could-not-check condition)", code) + } + if !strings.Contains(stdout.String(), "usage") { + t.Fatalf("stdout = %q, want a usage message", stdout.String()) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty — help goes to stdout", stderr.String()) + } + }) + } +} + +func TestRun_UnknownSubcommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"bogus"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), `"bogus"`) { + t.Fatalf("stderr = %q, want it to name the unknown subcommand", stderr.String()) + } +} + +func TestRun_List_MissingEnv(t *testing.T) { + t.Setenv("GRAFANA_URL", "") + t.Setenv("GRAFANA_TOKEN", "") + var stdout, stderr bytes.Buffer + code := run([]string{"list"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "GRAFANA_URL") { + t.Fatalf("stderr = %q, want it to name the missing env var", stderr.String()) + } +} diff --git a/grafana-alertcheck/internal/gate/resolve.go b/grafana-alertcheck/internal/gate/resolve.go new file mode 100644 index 000000000..3c3710038 --- /dev/null +++ b/grafana-alertcheck/internal/gate/resolve.go @@ -0,0 +1,209 @@ +package gate + +import ( + "fmt" + "slices" + "sort" + "strings" +) + +// Resolve turns the operator-supplied alert names into resolved Definitions +// (§17). Order is load-bearing (§17.3): +// +// 1. Trim each name. +// 2. Discard empty lines. +// 3. Resolve each name to a UID (this is what resolveOne does). +// 4. Collapse the result by UID — two names hitting the same rule is a note, +// never an error (almost always a copy mistake, and a message costs the +// user less than a failure). +// +// The caller-visible consequence: len(resolved) is the count *after* the +// collapse. A later phase's MinObserved must default from that length, never +// from len(names) — using the input line count would make one rule named +// twice turn an achievable default into an unsatisfiable one (§17.3). +func Resolve(defs []Definition, names []string, folder string) (resolved []Definition, notes []string, err error) { + seenUID := map[string]string{} // uid -> the first input name that resolved to it + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + + def, rerr := resolveOne(defs, name, folder) + if rerr != nil { + return nil, nil, rerr + } + + if firstName, ok := seenUID[def.UID]; ok { + notes = append(notes, fmt.Sprintf( + "%q and %q both resolve to %s (uid:%s); counted once", firstName, name, def.Title, def.UID)) + continue + } + seenUID[def.UID] = name + resolved = append(resolved, def) + } + return resolved, notes, nil +} + +// resolveOne resolves a single trimmed, non-empty name against defs (§17.1): +// one match wins outright, zero is an error with suggestions, two or more is +// an error listing every candidate. folder scopes a bare title (no "/" in the +// name) to one folder; it is ignored for the "Folder/Title" and +// "Folder/Group/Title" forms, which already name their own folder. +// +// Policy on unsupported kinds (datasource-managed, recording) — decided here +// because §17.1 only says to refuse them, not how they interact with the +// no-match/ambiguous surfaces: a name can still match an unsupported rule (so +// naming one by title still gets the specific, named refusal, not a bare "no +// match"), but only *supported* candidates count for ambiguity — an +// unsupported rule sharing a title with a supported one is resolved silently +// in the supported rule's favor rather than reported as ambiguous — and the +// "%d rules available" count and substring suggestions in a genuine no-match +// are scoped to supported rules only, so an unsupported rule never inflates +// or pollutes either. uid: is always exact regardless of kind (typically +// copy-pasted from `list`, which already shows Kind). +func resolveOne(defs []Definition, name, folder string) (Definition, error) { + if uid, ok := strings.CutPrefix(name, "uid:"); ok { + if uid != "" { + for _, d := range defs { + if d.UID == uid { + return refuseUnsupportedKind(name, d) + } + } + } + // uid == "" falls through to the same message as "not found": several + // Definition kinds legitimately carry UID == "" (datasource-managed + // rules have no uid at all, P1.3), so matching on an empty suffix + // would silently hit one of those and report a misleading + // kind-specific refusal for what is really an empty/typo'd uid. This + // deliberately does not go through noMatchError: that function's + // substring suggestion would degenerate to an empty needle, which + // strings.Contains matches against every title — printing the whole + // fleet instead of a real suggestion. + return Definition{}, fmt.Errorf("no rule matched %q: no rule has this uid (run 'grafana-alertcheck list' to see uids)", name) + } + + wantFolder, wantGroup, wantTitle, err := classifyForm(name, folder) + if err != nil { + return Definition{}, err + } + + var supportedCandidates, unsupportedCandidates []Definition + for _, d := range defs { + if wantFolder != "" && d.Folder != wantFolder { + continue + } + if wantGroup != "" && d.Group != wantGroup { + continue + } + if d.Title != wantTitle { + continue + } + if d.Kind == KindGrafanaManaged { + supportedCandidates = append(supportedCandidates, d) + } else { + unsupportedCandidates = append(unsupportedCandidates, d) + } + } + + switch { + case len(supportedCandidates) == 1: + return supportedCandidates[0], nil + case len(supportedCandidates) > 1: + return Definition{}, ambiguousError(name, supportedCandidates) + case len(unsupportedCandidates) > 0: + return refuseUnsupportedKind(name, unsupportedCandidates[0]) + default: + return Definition{}, noMatchError(supportedDefs(defs), name, wantTitle) + } +} + +// supportedDefs filters out the two kinds §17.1 refuses. Only these +// participate in name-based matching, the no-match rule count, and substring +// suggestions (see the policy note on resolveOne). +func supportedDefs(defs []Definition) []Definition { + out := make([]Definition, 0, len(defs)) + for _, d := range defs { + if d.Kind == KindGrafanaManaged { + out = append(out, d) + } + } + return out +} + +// classifyForm splits name into the Title | Folder/Title | Folder/Group/Title +// forms (§17). A bare title is scoped by folder when the caller supplied one; +// the two- and three-segment forms already carry their own folder and ignore +// it. +// +// Every segment must be non-empty. Without this, "/Title" would parse as an +// empty wantFolder — silently dropping the folder filter and matching +// unscoped, a fail-open — and "Folder/" would parse as an empty wantTitle, +// which would then feed noMatchError's substring search an empty needle that +// matches every title. +func classifyForm(name, folder string) (wantFolder, wantGroup, wantTitle string, err error) { + parts := strings.Split(name, "/") + if slices.Contains(parts, "") { + return "", "", "", fmt.Errorf("no rule matched %q: empty /-separated segment (want Title, Folder/Title, or Folder/Group/Title)", name) + } + switch len(parts) { + case 1: + return folder, "", parts[0], nil + case 2: + return parts[0], "", parts[1], nil + case 3: + return parts[0], parts[1], parts[2], nil + default: + return "", "", "", fmt.Errorf("no rule matched %q: too many /-separated segments (want Title, Folder/Title, or Folder/Group/Title)", name) + } +} + +// refuseUnsupportedKind rejects the two kinds §17.1 names explicitly with a +// clear, specific error — distinct from "no match" and from "ambiguous" — so +// an operator who names a recording or datasource-managed rule learns why, +// not just that nothing matched. +func refuseUnsupportedKind(name string, d Definition) (Definition, error) { + switch d.Kind { + case KindDatasourceManaged: + return Definition{}, fmt.Errorf("%q resolves to %s, a datasource-managed rule, which is not supported", name, d.Title) + case KindRecording: + return Definition{}, fmt.Errorf("%q resolves to %s, a recording rule, which is not supported", name, d.Title) + default: + return d, nil + } +} + +// noMatchError reports a no-match with the count of rules the gate could see +// and, per Context decision 4, case-insensitive substring matches in place of +// the source plan's cut Levenshtein suggestions (§17.2). +func noMatchError(defs []Definition, name, wantTitle string) error { + msg := fmt.Sprintf("no rule matched %q (%d rules available; run 'grafana-alertcheck list' to see titles)", name, len(defs)) + + needle := strings.ToLower(wantTitle) + var subs []string + for _, d := range defs { + if strings.Contains(strings.ToLower(d.Title), needle) { + subs = append(subs, fmt.Sprintf("%s/%s/%s", d.Folder, d.Group, d.Title)) + } + } + if len(subs) > 0 { + sort.Strings(subs) + msg += fmt.Sprintf("; did you mean: %s", strings.Join(subs, ", ")) + } + return fmt.Errorf("%s", msg) +} + +// ambiguousError lists every candidate with its folder, its group, and the +// full copyable Folder/Group/Title (§17.1) — including the uid: form, which +// resolves unambiguously on the next attempt. +func ambiguousError(name string, candidates []Definition) error { + sorted := append([]Definition(nil), candidates...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].UID < sorted[j].UID }) + + var b strings.Builder + fmt.Fprintf(&b, "%q matches %d rules; use uid: or the full Folder/Group/Title:", name, len(sorted)) + for _, d := range sorted { + fmt.Fprintf(&b, "\n %s/%s/%s (uid:%s)", d.Folder, d.Group, d.Title, d.UID) + } + return fmt.Errorf("%s", b.String()) +} diff --git a/grafana-alertcheck/internal/gate/resolve_test.go b/grafana-alertcheck/internal/gate/resolve_test.go new file mode 100644 index 000000000..11c69d6fb --- /dev/null +++ b/grafana-alertcheck/internal/gate/resolve_test.go @@ -0,0 +1,273 @@ +package gate + +import ( + "fmt" + "strings" + "testing" +) + +func rulerDefs(t *testing.T) []Definition { + t.Helper() + defs, err := ParseDefinitions(readFixture(t, "ruler_rules.json")) + if err != nil { + t.Fatalf("ParseDefinitions: unexpected error: %v", err) + } + return defs +} + +func TestResolve_SingleMatch(t *testing.T) { + defs := rulerDefs(t) + resolved, notes, err := Resolve(defs, []string{"example_workflow_paused_rule"}, "") + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if len(notes) != 0 { + t.Errorf("notes = %v, want none", notes) + } + if len(resolved) != 1 || resolved[0].UID != "rule0000007" { + t.Fatalf("resolved = %+v, want [rule0000007]", resolved) + } +} + +func TestResolve_UIDForm(t *testing.T) { + defs := rulerDefs(t) + resolved, _, err := Resolve(defs, []string{"uid:rule0000006a"}, "") + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if len(resolved) != 1 || resolved[0].UID != "rule0000006a" { + t.Fatalf("resolved = %+v, want [rule0000006a]", resolved) + } +} + +func TestResolve_FolderGroupTitleForm(t *testing.T) { + defs := rulerDefs(t) + resolved, _, err := Resolve(defs, []string{"Example-Zone-A/Gateway/Example No Gateways Available"}, "") + if err == nil { + t.Fatalf("Resolve: want ambiguous error (real 2-way collision), got resolved=%+v", resolved) + } + if !strings.Contains(err.Error(), "matches 2 rules") { + t.Fatalf("Resolve: error = %q, want it to report 2 matches", err) + } + if !strings.Contains(err.Error(), "uid:rule0000006a") || !strings.Contains(err.Error(), "uid:rule0000006b") { + t.Fatalf("Resolve: error = %q, want both candidate uids listed", err) + } +} + +func TestResolve_TrueCollisionResolvesByUID(t *testing.T) { + defs := rulerDefs(t) + resolved, _, err := Resolve(defs, []string{"uid:rule0000006a", "uid:rule0000006b"}, "") + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if len(resolved) != 2 { + t.Fatalf("resolved = %+v, want 2 distinct rules", resolved) + } +} + +func TestResolve_NoMatch(t *testing.T) { + defs := rulerDefs(t) + _, _, err := Resolve(defs, []string{"Does Not Exist"}, "") + if err == nil { + t.Fatal("Resolve: want error for unknown name") + } + if !strings.Contains(err.Error(), "no rule matched") || !strings.Contains(err.Error(), "list") { + t.Errorf("Resolve: error = %q, want it to name 'no rule matched' and point at 'list'", err) + } +} + +func TestResolve_NoMatchSubstringSuggestion(t *testing.T) { + defs := rulerDefs(t) + _, _, err := Resolve(defs, []string{"paused rule"}, "") + if err == nil { + t.Fatal("Resolve: want error for unknown name") + } + if !strings.Contains(err.Error(), "did you mean") || !strings.Contains(err.Error(), "Example Paused Rule") { + t.Errorf("Resolve: error = %q, want a case-insensitive substring suggestion", err) + } +} + +func TestResolve_RefusesDatasourceManaged(t *testing.T) { + defs, err := ParseDefinitions(readFixture(t, "ruler_datasource_managed.json")) + if err != nil { + t.Fatalf("ParseDefinitions: unexpected error: %v", err) + } + _, _, err = Resolve(defs, []string{"ExampleTargetDown"}, "") + if err == nil { + t.Fatal("Resolve: want refusal for a datasource-managed rule") + } + if !strings.Contains(err.Error(), "datasource-managed") { + t.Errorf("Resolve: error = %q, want it to name the datasource-managed kind", err) + } +} + +func TestResolve_RefusesRecording(t *testing.T) { + defs, err := ParseDefinitions(readFixture(t, "ruler_recording.json")) + if err != nil { + t.Fatalf("ParseDefinitions: unexpected error: %v", err) + } + _, _, err = Resolve(defs, []string{"uid:rule0000011"}, "") + if err == nil { + t.Fatal("Resolve: want refusal for a recording rule") + } + if !strings.Contains(err.Error(), "recording rule") { + t.Errorf("Resolve: error = %q, want it to name the recording kind", err) + } +} + +func TestResolve_RejectsEmptySegments(t *testing.T) { + defs := rulerDefs(t) + cases := []string{"/Title", "Folder/", "a//b"} + for _, name := range cases { + t.Run(name, func(t *testing.T) { + _, _, err := Resolve(defs, []string{name}, "") + if err == nil { + t.Fatalf("Resolve(%q): want error for an empty /-separated segment", name) + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("Resolve(%q): error = %q, want it to name the empty segment", name, err) + } + }) + } +} + +func TestResolve_UIDEmptySuffix(t *testing.T) { + // ruler_datasource_managed.json's only rule has UID == "" (P1.3: this + // shape has no uid at all). "uid:" with an empty suffix must not match it + // — that would report the misleading "datasource-managed rule, not + // supported" for what is really a typo'd/empty uid. + defs, err := ParseDefinitions(readFixture(t, "ruler_datasource_managed.json")) + if err != nil { + t.Fatalf("ParseDefinitions: unexpected error: %v", err) + } + _, _, err = Resolve(defs, []string{"uid:"}, "") + if err == nil { + t.Fatal("Resolve: want error for an empty uid: suffix") + } + if !strings.Contains(err.Error(), "no rule has this uid") { + t.Errorf("Resolve: error = %q, want it to say no rule has this uid", err) + } + if strings.Contains(err.Error(), "datasource-managed") { + t.Errorf("Resolve: error = %q, must not misreport this as a datasource-managed refusal", err) + } +} + +func TestResolve_UnsupportedKindsExcludedFromNoMatchSurfaces(t *testing.T) { + dsDefs, err := ParseDefinitions(readFixture(t, "ruler_datasource_managed.json")) + if err != nil { + t.Fatalf("ParseDefinitions(datasource_managed): unexpected error: %v", err) + } + recDefs, err := ParseDefinitions(readFixture(t, "ruler_recording.json")) + if err != nil { + t.Fatalf("ParseDefinitions(recording): unexpected error: %v", err) + } + supported := rulerDefs(t) + combined := append(append(append([]Definition{}, supported...), dsDefs...), recDefs...) + + _, _, err = Resolve(combined, []string{"Example"}, "") + if err == nil { + t.Fatal("Resolve: want a no-match error for a name matching no title exactly") + } + + wantCount := fmt.Sprintf("(%d rules available", len(supported)) + if !strings.Contains(err.Error(), wantCount) { + t.Errorf("Resolve: error = %q, want the available count scoped to the %d supported rules, not the %d combined", err, len(supported), len(combined)) + } + if strings.Contains(err.Error(), "ExampleTargetDown") { + t.Errorf("Resolve: error = %q, must not suggest the datasource-managed rule", err) + } + if strings.Contains(err.Error(), "example:recorded_metric:rate5m") { + t.Errorf("Resolve: error = %q, must not suggest the recording rule", err) + } + if !strings.Contains(err.Error(), "Example Paused Rule") { + t.Errorf("Resolve: error = %q, want it to still suggest a matching supported rule", err) + } +} + +func TestResolve_UnsupportedHomonymResolvesSupportedSilently(t *testing.T) { + // Synthetic: a supported and an unsupported rule sharing an identical + // Folder/Group/Title. Real Grafana data has no such case in the capture, + // but the policy must not treat this as ambiguous — the unsupported rule + // is invisible next to a same-named supported one. + defs := []Definition{ + {UID: "supported-1", Folder: "F", Group: "G", Title: "Shared Title", Kind: KindGrafanaManaged}, + {UID: "", Folder: "F", Group: "G", Title: "Shared Title", Kind: KindDatasourceManaged}, + } + resolved, _, err := Resolve(defs, []string{"F/G/Shared Title"}, "") + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if len(resolved) != 1 || resolved[0].UID != "supported-1" { + t.Fatalf("resolved = %+v, want the supported rule alone, no ambiguity", resolved) + } +} + +func TestResolve_CollapseByUIDGivesNoteNotError(t *testing.T) { + defs := rulerDefs(t) + // The bare title and its Folder/Group/Title spelling both name the same + // rule (rule0000007) — a duplicate-name copy mistake, not an error + // (§17.3). + resolved, notes, err := Resolve(defs, []string{ + "example_workflow_paused_rule", + "ExampleObservability/Example Auth Production/example_workflow_paused_rule", + }, "") + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if len(resolved) != 1 || resolved[0].UID != "rule0000007" { + t.Fatalf("resolved = %+v, want exactly one rule0000007", resolved) + } + if len(notes) != 1 { + t.Fatalf("notes = %v, want exactly one collapse note", notes) + } +} + +func TestResolve_MinObservedCountIsPostCollapse(t *testing.T) { + defs := rulerDefs(t) + names := []string{ + "example_workflow_paused_rule", + "ExampleObservability/Example Auth Production/example_workflow_paused_rule", // duplicate of the same rule + "Example Paused Rule", + } + resolved, notes, err := Resolve(defs, names, "") + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + // §17.3: the default MinObserved must come from len(resolved) (2 distinct + // rules) — never len(names) (3 input lines), which would be unsatisfiable. + if len(resolved) != 2 { + t.Fatalf("resolved = %+v, want 2 distinct rules after collapse", resolved) + } + if len(notes) != 1 { + t.Fatalf("notes = %v, want exactly one collapse note", notes) + } +} + +func TestResolve_EmptyAndBlankLinesDiscarded(t *testing.T) { + defs := rulerDefs(t) + resolved, _, err := Resolve(defs, []string{"", " ", "example_workflow_paused_rule", " \t "}, "") + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if len(resolved) != 1 || resolved[0].UID != "rule0000007" { + t.Fatalf("resolved = %+v, want [rule0000007]", resolved) + } +} + +func TestResolve_FolderScopesBareTitle(t *testing.T) { + defs := rulerDefs(t) + // Bare title, scoped to the wrong folder — must not match. + _, _, err := Resolve(defs, []string{"example_workflow_paused_rule"}, "Example-Zone-A") + if err == nil { + t.Fatal("Resolve: want no-match when folder scope excludes the only candidate") + } + + // Scoped to the right folder — must match. + resolved, _, err := Resolve(defs, []string{"example_workflow_paused_rule"}, "ExampleObservability") + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if len(resolved) != 1 || resolved[0].UID != "rule0000007" { + t.Fatalf("resolved = %+v, want [rule0000007]", resolved) + } +} From 375b1b4d092b908994f065baf0ae7e171161b0a3 Mon Sep 17 00:00:00 2001 From: Bartek Tofel Date: Tue, 1 Sep 2026 16:43:56 +0200 Subject: [PATCH 2/2] chore: enhance unit tests --- grafana-alertcheck/internal/gate/resolve_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafana-alertcheck/internal/gate/resolve_test.go b/grafana-alertcheck/internal/gate/resolve_test.go index 11c69d6fb..209b84bd5 100644 --- a/grafana-alertcheck/internal/gate/resolve_test.go +++ b/grafana-alertcheck/internal/gate/resolve_test.go @@ -117,7 +117,7 @@ func TestResolve_RefusesRecording(t *testing.T) { func TestResolve_RejectsEmptySegments(t *testing.T) { defs := rulerDefs(t) - cases := []string{"/Title", "Folder/", "a//b"} + cases := []string{"/Title", "Folder/", "a//b", "//", "/"} for _, name := range cases { t.Run(name, func(t *testing.T) { _, _, err := Resolve(defs, []string{name}, "")