From 70e30df6ce4d264e73a24561a28ddd41ccc4b460 Mon Sep 17 00:00:00 2001 From: Moustafa Baiou Date: Mon, 10 Nov 2025 11:43:38 -0500 Subject: [PATCH] Alerting: Fix support for converted Prometheus rules in app-platform apis (#113648) * Alerting: Fix support for converted Prometheus rules in app-platform apis Retrieving converted Prometheus retrieval rules was not supported in the app-platform apis and was throwing a 500 error due to the provenance not being handled properly. Also adds a test to cover converted Prometheus rules when getting rules. Closes https://github.com/grafana/alerting-squad/issues/1200 * add test to confirm provenance compatibility --- .../rules/pkg/apis/alerting/v0alpha1/ext.go | 10 +- .../convertprometheus_retrieval_test.go | 227 ++++++++++++++++++ 2 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 pkg/tests/apis/alerting/rules/compat/convertprometheus_retrieval_test.go diff --git a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go index 73606736a3f..f4efc73e014 100644 --- a/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go +++ b/apps/alerting/rules/pkg/apis/alerting/v0alpha1/ext.go @@ -19,13 +19,17 @@ const ( FolderLabelKey = FolderAnnotationKey ) +// NOTE: This is a copy of the constants from the alertrule package to avoid circular imports. +// Keep in sync with pkg/services/ngalert/models/provisioning.go const ( - ProvenanceStatusNone = "" - ProvenanceStatusAPI = "api" + ProvenanceStatusNone = "" + ProvenanceStatusAPI = "api" + ProvenanceStatusFile = "file" + ProvenanceStatusConvertedPrometheus = "converted_prometheus" ) var ( - AcceptedProvenanceStatuses = []string{ProvenanceStatusNone, ProvenanceStatusAPI} + AcceptedProvenanceStatuses = []string{ProvenanceStatusNone, ProvenanceStatusAPI, ProvenanceStatusFile, ProvenanceStatusConvertedPrometheus} ) func ToDuration(s string) (time.Duration, error) { diff --git a/pkg/tests/apis/alerting/rules/compat/convertprometheus_retrieval_test.go b/pkg/tests/apis/alerting/rules/compat/convertprometheus_retrieval_test.go new file mode 100644 index 00000000000..21a89180209 --- /dev/null +++ b/pkg/tests/apis/alerting/rules/compat/convertprometheus_retrieval_test.go @@ -0,0 +1,227 @@ +package compat + +import ( + "context" + "encoding/json" + "testing" + "time" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + prom_model "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/tests/api/alerting" + "github.com/grafana/grafana/pkg/tests/apis/alerting/rules/common" + "github.com/grafana/grafana/pkg/util/testutil" +) + +// TestIntegrationConvertPrometheusAlertRuleRetrieval verifies that an alert rule created +// through the Prometheus (mimirtool compatible) conversion API can be retrieved via the +// new Kubernetes rules API and retains expected fields (title, expressions, interval, +// folder annotation, group labels, and provenance converted_prometheus). +func TestIntegrationConvertPrometheusAlertRuleRetrieval(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + helper := common.GetTestHelper(t) + + // K8s client for new rules API + k8sAlertClient := common.NewAlertRuleClient(t, helper.Org1.Admin) + + // Legacy + conversion API client + legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin") + + // Ensure legacy API is enabled (sanity) + allRules, status, _ := legacyClient.GetAllRulesWithStatus(t) + require.Equal(t, 200, status) + require.NotNil(t, allRules) + + // Create folder that will act as namespace title + folderUID := "test-folder-convert" + common.CreateTestFolder(t, helper, folderUID) + + // Build a Prometheus-compatible rule group payload (minimal) for conversion API + // We simulate a simple alert rule with expr and for duration; conversion API will set provenance. + // We pick static values; Grafana rule title will mirror Prometheus 'alert' name. + forDuration := prom_model.Duration(10 * time.Second) + interval20, err := prom_model.ParseDuration("20s") + require.NoError(t, err) + promGroup := apimodels.PrometheusRuleGroup{ + Name: "test-group", + Interval: interval20, + Rules: []apimodels.PrometheusRule{ + { + Alert: "ConvertedAlertTest", + Expr: "vector(1)", // simple always firing expression + For: &forDuration, + Labels: map[string]string{ + "severity": "critical", + }, + Annotations: map[string]string{ + "summary": "Converted alert rule test", + }, + }, + }, + } + + // Create a real Prometheus datasource; conversion API requires a Prometheus-compatible datasource (cannot use __expr__). + ds := legacyClient.CreateDatasource(t, "prometheus") + dsUID := ds.Body.Datasource.UID + require.NotEmpty(t, dsUID, "prometheus datasource UID must not be empty") + defer legacyClient.DeleteDatasource(t, dsUID) + headers := map[string]string{} + + // Post conversion (client signature: namespaceTitle, datasourceUID, promGroup, headers) + // Use the folder title as the namespace title for conversion API lookup. + // Our helper created a folder with title "Test Folder" and UID folderUID. + resp := legacyClient.ConvertPrometheusPostRuleGroup(t, "Test Folder", dsUID, promGroup, headers) + require.Equal(t, "success", resp.Status) + + // Retrieve the converted rule via new K8s API immediately after the API call. + ruleList, err := k8sAlertClient.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + var found *v0alpha1.AlertRule + for _, item := range ruleList.Items { + if item.Spec.Title == "ConvertedAlertTest" { // Title should match the Prometheus alert name + copy := item.DeepCopy() + found = copy + break + } + } + require.NotNil(t, found, "expected to find converted alert rule via K8s API") + + // Assertions on converted rule fields + require.Equal(t, folderUID, found.Annotations["grafana.app/folder"], "folder annotation must match folder UID") + require.Equal(t, "test-group", found.Labels[v0alpha1.GroupLabelKey], "group label must match group name") + require.NotEmpty(t, found.Labels[v0alpha1.GroupIndexLabelKey], "group index label must be populated") + + // Interval: parse prom group interval and compare with spec trigger interval + require.Equal(t, promGroup.Interval.String(), string(found.Spec.Trigger.Interval), "interval mismatch") + + // Expression/model checks: Alert rule conversion produces query + math + threshold nodes. + require.Equal(t, 3, len(found.Spec.Expressions), "expected three expressions (query, math, threshold) in converted alert rule") + for ref, exp := range found.Spec.Expressions { + require.NotNil(t, exp.Model, "expression model %s should not be nil", ref) + // Only query expression carries a datasource UID; math/threshold may omit it. If present, must match dsUID. + if exp.DatasourceUID != nil { + require.EqualValues(t, dsUID, *exp.DatasourceUID) + } + } + + // Provenance should be converted_prometheus + require.Equal(t, ngmodels.ProvenanceConvertedPrometheus, ngmodels.Provenance(found.GetProvenanceStatus()), "provenance mismatch") + + // Basic JSON model sanity (non-empty if map) + // Basic JSON model sanity for at least one expression + var sanityChecked bool + for _, exp := range found.Spec.Expressions { + if m, ok := exp.Model.(map[string]interface{}); ok { + require.NotEmpty(t, m) + sanityChecked = true + break + } + } + require.True(t, sanityChecked, "expected at least one expression model to be a non-empty map") +} + +// TestIntegrationConvertPrometheusRecordingRuleRetrieval verifies recording rule conversion retrieval via K8s API. +func TestIntegrationConvertPrometheusRecordingRuleRetrieval(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + helper := common.GetTestHelper(t) + + k8sRecordingClient := common.NewRecordingRuleClient(t, helper.Org1.Admin) + legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin") + + allRules, status, _ := legacyClient.GetAllRulesWithStatus(t) + require.Equal(t, 200, status) + require.NotNil(t, allRules) + + folderUID := "test-folder-convert-record" + common.CreateTestFolder(t, helper, folderUID) + + forDuration := prom_model.Duration(5 * time.Second) + interval20, err := prom_model.ParseDuration("20s") + require.NoError(t, err) + promGroup := apimodels.PrometheusRuleGroup{ + Name: "test-group-rec", + Interval: interval20, + Rules: []apimodels.PrometheusRule{ + { + Record: "converted_metric_total", + Expr: "vector(2)", + For: &forDuration, // For is ignored for recording rules but included for consistency + Labels: map[string]string{"job": "demo"}, + Annotations: map[string]string{"summary": "Converted recording rule test"}, + }, + }, + } + // Create a real Prometheus datasource for recording rule conversion. + ds := legacyClient.CreateDatasource(t, "prometheus") + dsUID := ds.Body.Datasource.UID + require.NotEmpty(t, dsUID, "prometheus datasource UID must not be empty") + defer legacyClient.DeleteDatasource(t, dsUID) + headers := map[string]string{ + "X-Grafana-Alerting-Target-Datasource-UID": dsUID, + } + + resp := legacyClient.ConvertPrometheusPostRuleGroup(t, "Test Folder", dsUID, promGroup, headers) + require.Equal(t, "success", resp.Status) + + // Retrieve the converted recording rule immediately + list, err := k8sRecordingClient.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + var found *v0alpha1.RecordingRule + for _, item := range list.Items { + if item.Spec.Metric == "converted_metric_total" { + copy := item.DeepCopy() + found = copy + break + } + } + require.NotNil(t, found, "expected to find converted recording rule via K8s API") + + require.Equal(t, folderUID, found.Annotations["grafana.app/folder"], "folder annotation must match") + require.Equal(t, "test-group-rec", found.Labels[v0alpha1.GroupLabelKey]) + require.NotEmpty(t, found.Labels[v0alpha1.GroupIndexLabelKey]) + + require.Equal(t, promGroup.Interval.String(), string(found.Spec.Trigger.Interval)) + + // Verify expressions map non-empty + require.Equal(t, 1, len(found.Spec.Expressions)) + var exprSpec v0alpha1.RecordingRuleExpression + for _, v := range found.Spec.Expressions { + exprSpec = v + break + } + require.NotNil(t, exprSpec.Model) + + // Datasource should match provided header + require.EqualValues(t, dsUID, *exprSpec.DatasourceUID) + + require.Equal(t, ngmodels.ProvenanceConvertedPrometheus, ngmodels.Provenance(found.GetProvenanceStatus())) + + if m, ok := exprSpec.Model.(map[string]interface{}); ok { + require.NotEmpty(t, m) + } else if b, ok := exprSpec.Model.([]byte); ok { + tmp := map[string]interface{}{} + _ = json.Unmarshal(b, &tmp) + require.NotEmpty(t, tmp) + } +} + +// If this test fails, it indicates that the accepted provenance statuses +// in v0alpha1/ext.go are out of sync with the known provenances in ngalert models. +// Make sure to keep them in sync. +func TestProvenancesMatch(t *testing.T) { + modelProvenances := make([]string, 0, len(ngmodels.KnownProvenances)) + for _, p := range ngmodels.KnownProvenances { + modelProvenances = append(modelProvenances, string(p)) + } + require.ElementsMatch(t, v0alpha1.AcceptedProvenanceStatuses, modelProvenances) +}