diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index 97afcf561d1..1d4fd9ac814 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -56,6 +56,10 @@ const ( // The value should be comma-separated key=value pairs, e.g., "environment=production,team=alerting". mergeMatchersHeader = "X-Grafana-Alerting-Merge-Matchers" + // extraLabelsHeader is the header that specifies extra labels to be added to all imported rules. + // The value should be comma-separated key=value pairs, e.g., "environment=production,team=alerting". + extraLabelsHeader = "X-Grafana-Alerting-Extra-Labels" + // configIdentifierHeader is the header that specifies the identifier for imported Alertmanager config. configIdentifierHeader = "X-Grafana-Alerting-Config-Identifier" defaultConfigIdentifier = "default" @@ -397,6 +401,12 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context return errorToResponse(err) } + extraLabels, err := parseExtraLabelsHeader(c) + if err != nil { + logger.Error("Failed to parse extra labels header", "error", err) + return errorToResponse(err) + } + // 2. Convert Prometheus Rules to GMA grafanaGroups := make([]*models.AlertRuleGroup, 0, len(promNamespaces)) for ns, rgs := range promNamespaces { @@ -427,6 +437,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context pauseAlertRules, keepOriginalRuleDefinition, notificationSettings, + extraLabels, logger, ) if err != nil { @@ -477,6 +488,7 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup( pauseAlertRules bool, keepOriginalRuleDefinition bool, notificationSettings []models.NotificationSettings, + extraLabels map[string]string, logger log.Logger, ) (*models.AlertRuleGroup, error) { logger.Info("Converting Prometheus rules to Grafana rules", "rules", len(promGroup.Rules), "folder_uid", namespaceUID, "datasource_uid", ds.UID, "datasource_type", ds.Type) @@ -518,6 +530,7 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup( KeepOriginalRuleDefinition: util.Pointer(keepOriginalRuleDefinition), EvaluationOffset: &srv.cfg.PrometheusConversion.RuleQueryOffset, NotificationSettings: notificationSettings, + ExtraLabels: extraLabels, }, ) if err != nil { @@ -758,6 +771,35 @@ func parseNotificationSettingsHeader(ctx *contextmodel.ReqContext) ([]models.Not return notificationSettings, nil } +// parseKeyValuePairs parses a comma-separated list of key=value pairs. +// Expected format: "key1=value1,key2=value2" +func parseKeyValuePairs(input string, headerName string) (map[string]string, error) { + input = strings.TrimSpace(input) + if input == "" { + return nil, nil + } + + result := make(map[string]string) + + for pair := range strings.SplitSeq(input, ",") { + parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(parts) != 2 { + return nil, errInvalidHeaderValue(headerName, errors.New("format should be 'key=value,key2=value2'")) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + if key == "" || value == "" { + return nil, errInvalidHeaderValue(headerName, errors.New("keys and values cannot be empty")) + } + + result[key] = value + } + + return result, nil +} + // parseMergeMatchersHeader parses the merge matchers header value. // Expected format: "key1=value1,key2=value2" func parseMergeMatchersHeader(c *contextmodel.ReqContext) (amconfig.Matchers, error) { @@ -767,21 +809,13 @@ func parseMergeMatchersHeader(c *contextmodel.ReqContext) (amconfig.Matchers, er return amconfig.Matchers{}, errInvalidHeaderValue(mergeMatchersHeader, errors.New("value cannot be empty")) } + kvPairs, err := parseKeyValuePairs(matchersStr, mergeMatchersHeader) + if err != nil { + return nil, err + } + matchers := amconfig.Matchers{} - - for pair := range strings.SplitSeq(matchersStr, ",") { - parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) - if len(parts) != 2 { - return nil, errInvalidHeaderValue(mergeMatchersHeader, errors.New("format should be 'key=value,key2=value2'")) - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - if key == "" || value == "" { - return nil, errInvalidHeaderValue(mergeMatchersHeader, errors.New("keys and values cannot be empty")) - } - + for key, value := range kvPairs { matchers = append(matchers, &labels.Matcher{ Type: labels.MatchEqual, Name: key, @@ -792,6 +826,13 @@ func parseMergeMatchersHeader(c *contextmodel.ReqContext) (amconfig.Matchers, er return matchers, nil } +// parseExtraLabelsHeader parses the extra labels header value. +// Expected format: "key1=value1,key2=value2" +func parseExtraLabelsHeader(c *contextmodel.ReqContext) (map[string]string, error) { + labelsStr := strings.TrimSpace(c.Req.Header.Get(extraLabelsHeader)) + return parseKeyValuePairs(labelsStr, extraLabelsHeader) +} + func formatMergeMatchers(matchers amconfig.Matchers) string { var pairs []string for _, matcher := range matchers { diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index ca7833361c2..44753418cce 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -292,6 +292,91 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { } }) + t.Run("with extra labels header should apply labels to all rules", func(t *testing.T) { + srv, _, ruleStore := createConvertPrometheusSrv(t) + rc := createRequestCtx() + rc.Req.Header.Set(extraLabelsHeader, "environment=production,team=alerting") + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusAccepted, response.Status()) + + rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + }) + require.NoError(t, err) + require.Len(t, rules, 2) + + for _, rule := range rules { + require.Equal(t, "production", rule.Labels["environment"]) + require.Equal(t, "alerting", rule.Labels["team"]) + } + + // Original labels must be preserved + alertRule := rules[0] + if alertRule.Title == "recorded-metric" { + alertRule = rules[1] + } + require.Equal(t, "critical", alertRule.Labels["severity"]) + }) + + t.Run("with extra labels that conflict with rule labels", func(t *testing.T) { + srv, _, ruleStore := createConvertPrometheusSrv(t) + rc := createRequestCtx() + // rules in the simpleGroup already have a severity label, so + // it should not be overwritten by the label from the header + rc.Req.Header.Set(extraLabelsHeader, "environment=production,severity=low") + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusAccepted, response.Status()) + + rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + }) + require.NoError(t, err) + require.Len(t, rules, 2) + + for _, rule := range rules { + require.Equal(t, "production", rule.Labels["environment"]) + require.NotEqual(t, "low", rule.Labels["severity"]) + } + }) + + t.Run("with invalid extra labels header should return 400", func(t *testing.T) { + testCases := []struct { + name string + headerValue string + expectedError string + }{ + { + name: "missing equals sign", + headerValue: "environment,team=platform", + expectedError: "Invalid value for header X-Grafana-Alerting-Extra-Labels: format should be 'key=value,key2=value2'", + }, + { + name: "empty key", + headerValue: "=production,team=platform", + expectedError: "Invalid value for header X-Grafana-Alerting-Extra-Labels: keys and values cannot be empty", + }, + { + name: "empty value", + headerValue: "environment=,team=platform", + expectedError: "Invalid value for header X-Grafana-Alerting-Extra-Labels: keys and values cannot be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + srv, _, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + rc.Req.Header.Set(extraLabelsHeader, tc.headerValue) + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusBadRequest, response.Status()) + require.Contains(t, string(response.Body()), tc.expectedError) + }) + } + }) + t.Run("with empty rule group name should return 400", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index 97eece4edfc..4b8137f347b 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -59,6 +59,9 @@ type Config struct { RecordingRules RulesConfig AlertRules RulesConfig NotificationSettings []models.NotificationSettings + // ExtraLabels are labels that will be added to all rules during conversion. + // These labels have the lowest precedence and can be overridden by group or rule labels. + ExtraLabels map[string]string } // RulesConfig contains configuration that applies to either recording or alerting rules. @@ -232,7 +235,8 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom title = rule.Alert } - labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels)+1) + labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels)+len(p.cfg.ExtraLabels)+1) + maps.Copy(labels, p.cfg.ExtraLabels) maps.Copy(labels, promGroup.Labels) maps.Copy(labels, rule.Labels) diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index befa524f468..5d7b9470d00 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -659,6 +659,73 @@ func TestPrometheusRulesToGrafana_GroupLabels(t *testing.T) { }) } +func TestPrometheusRulesToGrafana_ExtraLabels(t *testing.T) { + cfg := Config{ + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + DefaultInterval: 2 * time.Minute, + ExtraLabels: map[string]string{ + "extra_label": "extra_value", + "common_label": "extra_value", + "rule_label": "value_from_extra_labels", + }, + } + converter, err := NewConverter(cfg) + require.NoError(t, err) + + t.Run("extra labels are merged with group and rule labels", func(t *testing.T) { + promGroup := PrometheusRuleGroup{ + Name: "test-group-1", + Interval: prommodel.Duration(10 * time.Second), + Labels: map[string]string{ + "group_label": "group_value", + "common_label": "group_value", + }, + Rules: []PrometheusRule{ + { + Alert: "alert-1", + Expr: "cpu_usage > 80", + Labels: map[string]string{ + "rule_label": "rule_value", + }, + }, + }, + } + + grafanaGroup, err := converter.PrometheusRulesToGrafana(1, "namespace", promGroup) + require.NoError(t, err) + require.Len(t, grafanaGroup.Rules, 1) + + expectedLabels := withInternalLabel(map[string]string{ + "extra_label": "extra_value", + "common_label": "group_value", + "group_label": "group_value", + "rule_label": "rule_value", + }) + require.Equal(t, expectedLabels, grafanaGroup.Rules[0].Labels) + }) + + t.Run("extra labels are applied to recording rules", func(t *testing.T) { + promGroup := PrometheusRuleGroup{ + Name: "test-group-2", + Interval: prommodel.Duration(10 * time.Second), + Rules: []PrometheusRule{ + { + Record: "http_requests_total:rate5m", + Expr: "rate(http_requests_total[5m])", + }, + }, + } + + grafanaGroup, err := converter.PrometheusRulesToGrafana(1, "namespace", promGroup) + require.NoError(t, err) + require.Len(t, grafanaGroup.Rules, 1) + + expectedLabels := withInternalLabel(cfg.ExtraLabels) + require.Equal(t, expectedLabels, grafanaGroup.Rules[0].Labels) + }) +} + func TestPrometheusRulesToGrafana_UID(t *testing.T) { orgID := int64(1) namespace := "some-namespace"