diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index d14a37aa9f5..07495e0e2e1 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -5,10 +5,19 @@ import ( "fmt" "time" + "github.com/google/uuid" "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util" +) + +const ( + // ruleUIDLabel is a special label that can be used to set a custom UID for a Prometheus + // alert rule when converting it to a Grafana alert rule. If this label is not present, + // a stable UID will be generated automatically based on the rule's data. + ruleUIDLabel = "__grafana_alert_rule_uid__" ) type Config struct { @@ -114,6 +123,12 @@ func (p *Converter) convertRuleGroup(orgID int64, namespaceUID string, promGroup gr.Title = fmt.Sprintf("%s (%d)", gr.Title, val) } + uid, err := getUID(orgID, namespaceUID, promGroup.Name, i, rule) + if err != nil { + return nil, fmt.Errorf("failed to generate UID for rule '%s': %w", gr.Title, err) + } + gr.UID = uid + rules = append(rules, gr) } @@ -127,6 +142,24 @@ func (p *Converter) convertRuleGroup(orgID int64, namespaceUID string, promGroup return result, nil } +// getUID returns a UID for a Prometheus rule. +// If the rule has a special label its value is used. +// Otherwise, a stable UUID is generated by using a hash of the rule's data. +func getUID(orgID int64, namespaceUID string, group string, position int, promRule PrometheusRule) (string, error) { + if uid, ok := promRule.Labels[ruleUIDLabel]; ok { + if err := util.ValidateUID(uid); err != nil { + return "", fmt.Errorf("invalid UID label value: %s; %w", uid, err) + } + return uid, nil + } + + // Generate stable UUID based on the orgID, namespace, group and position. + uidData := fmt.Sprintf("%d|%s|%s|%d", orgID, namespaceUID, group, position) + u := uuid.NewSHA1(uuid.NameSpaceOID, []byte(uidData)) + + return u.String(), nil +} + func (p *Converter) convertRule(orgID int64, namespaceUID, group string, rule PrometheusRule) (models.AlertRule, error) { var forInterval time.Duration if rule.For != nil { diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index f175686fd3d..1b85c0d9d92 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -1,15 +1,18 @@ package prom import ( + "fmt" "testing" "time" + "github.com/google/uuid" prommodel "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util" ) func TestPrometheusRulesToGrafana(t *testing.T) { @@ -136,6 +139,10 @@ func TestPrometheusRulesToGrafana(t *testing.T) { expectedLabels[k] = v } + uidData := fmt.Sprintf("%d|%s|%s|%d", tc.orgID, tc.namespace, tc.promGroup.Name, j) + u := uuid.NewSHA1(uuid.NameSpaceOID, []byte(uidData)) + require.Equal(t, u.String(), grafanaRule.UID, tc.name) + require.Equal(t, expectedLabels, grafanaRule.Labels, tc.name) require.Equal(t, promRule.Annotations, grafanaRule.Annotations, tc.name) require.Equal(t, models.Duration(0*time.Minute), grafanaRule.Data[0].RelativeTimeRange.To) @@ -190,3 +197,105 @@ func TestPrometheusRulesToGrafanaWithDuplicateRuleNames(t *testing.T) { require.Equal(t, "another alert", group.Rules[2].Title) require.Equal(t, "alert (3)", group.Rules[3].Title) } + +func TestPrometheusRulesToGrafana_UID(t *testing.T) { + orgID := int64(1) + namespace := "some-namespace" + + promGroup := PrometheusRuleGroup{ + Name: "test-group-1", + Interval: prommodel.Duration(10 * time.Second), + Rules: []PrometheusRule{ + { + Alert: "alert-1", + Expr: "cpu_usage > 80", + For: util.Pointer(prommodel.Duration(5 * time.Minute)), + Labels: map[string]string{ + "severity": "critical", + ruleUIDLabel: "rule-uid-1", + }, + Annotations: map[string]string{ + "summary": "CPU usage is critical", + }, + }, + }, + } + + converter, err := NewConverter(Config{ + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + }) + require.NoError(t, err) + + t.Run("if not specified, UID is generated based on the rule index", func(t *testing.T) { + grafanaGroup, err := converter.PrometheusRulesToGrafana(orgID, namespace, promGroup) + require.NoError(t, err) + + firstUID := grafanaGroup.Rules[0].UID + + // Convert again + grafanaGroup, err = converter.PrometheusRulesToGrafana(orgID, namespace, promGroup) + require.NoError(t, err) + + secondUID := grafanaGroup.Rules[0].UID + + // They must be equal + require.NotEmpty(t, firstUID) + require.Equal(t, firstUID, secondUID) + }) + + t.Run("if the special label is specified", func(t *testing.T) { + t.Run("and the label is valid it should be used", func(t *testing.T) { + orgID := int64(1) + namespace := "some-namespace" + + converter, err := NewConverter(Config{ + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + }) + require.NoError(t, err) + + promGroup.Rules[0].Labels[ruleUIDLabel] = "rule-uid-1" + + grafanaGroup, err := converter.PrometheusRulesToGrafana(orgID, namespace, promGroup) + require.NoError(t, err) + + require.Equal(t, "rule-uid-1", grafanaGroup.Rules[0].UID) + }) + + t.Run("and the label is invalid", func(t *testing.T) { + orgID := int64(1) + namespace := "some-namespace" + + converter, err := NewConverter(Config{ + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + }) + require.NoError(t, err) + + // create a string of 50 characters + promGroup.Rules[0].Labels[ruleUIDLabel] = "aaaabbbbccccddddeeeeffffgggghhhhiiiijjjjkkkkllllmm" // too long + + grafanaGroup, err := converter.PrometheusRulesToGrafana(orgID, namespace, promGroup) + require.Errorf(t, err, "invalid UID label value") + require.Nil(t, grafanaGroup) + }) + + t.Run("and the label is empty", func(t *testing.T) { + orgID := int64(1) + namespace := "some-namespace" + + converter, err := NewConverter(Config{ + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + }) + require.NoError(t, err) + + promGroup.Rules[0].Labels[ruleUIDLabel] = "" + + grafanaGroup, err := converter.PrometheusRulesToGrafana(orgID, namespace, promGroup) + require.Errorf(t, err, "invalid UID label value") + require.Nil(t, grafanaGroup) + }) + }) +}