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
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user