diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index b47334df8cc..9877cd46ded 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "errors" "fmt" "net/http" @@ -21,6 +22,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/api/validation" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/prom" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" @@ -41,6 +43,10 @@ const ( // These headers control the paused state of newly created rules. By default, rules are not paused. recordingRulesPausedHeader = "X-Grafana-Alerting-Recording-Rules-Paused" alertRulesPausedHeader = "X-Grafana-Alerting-Alert-Rules-Paused" + + // notificationSettingsHeader is the header that specifies the notification settings to be used for the rules. + // The value should be a JSON-encoded AlertRuleNotificationSettings object. + notificationSettingsHeader = "X-Grafana-Alerting-Notification-Settings" ) var ( @@ -49,7 +55,7 @@ var ( errutil.WithPublicMessage(fmt.Sprintf("Missing datasource UID header: %s", datasourceUIDHeader)), ).Errorf("missing datasource UID header") - errInvalidHeaderValueMsg = "Invalid value for header {{.Public.Header}}: must be 'true' or 'false'" + errInvalidHeaderValueMsg = "Invalid value for header {{.Public.Header}}: {{.Public.Error}}" errInvalidHeaderValueBase = errutil.ValidationFailed("alerting.invalidHeaderValue").MustTemplate(errInvalidHeaderValueMsg, errutil.WithPublic(errInvalidHeaderValueMsg)) errRecordingRulesNotEnabled = errutil.ValidationFailed( @@ -63,8 +69,8 @@ var ( ).Errorf("recording rules target datasources configuration not enabled") ) -func errInvalidHeaderValue(header string) error { - return errInvalidHeaderValueBase.Build(errutil.TemplateData{Public: map[string]any{"Header": header}}) +func errInvalidHeaderValue(header string, err error) error { + return errInvalidHeaderValueBase.Build(errutil.TemplateData{Public: map[string]any{"Header": header, "Error": err}}) } // ConvertPrometheusSrv converts Prometheus rules to Grafana rules @@ -369,6 +375,12 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context // to ensure we can return them in this API in Prometheus format. keepOriginalRuleDefinition := provenance == models.ProvenanceConvertedPrometheus + notificationSettings, err := parseNotificationSettingsHeader(c) + if err != nil { + logger.Error("Failed to parse notification settings header", "error", err) + return errorToResponse(err) + } + // 2. Convert Prometheus Rules to GMA grafanaGroups := make([]*models.AlertRuleGroup, 0, len(promNamespaces)) for ns, rgs := range promNamespaces { @@ -394,7 +406,18 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context } } - grafanaGroup, err := srv.convertToGrafanaRuleGroup(c, ds, tds, namespace.UID, rg, pauseRecordingRules, pauseAlertRules, keepOriginalRuleDefinition, logger) + grafanaGroup, err := srv.convertToGrafanaRuleGroup( + c, + ds, + tds, + namespace.UID, + rg, + pauseRecordingRules, + pauseAlertRules, + keepOriginalRuleDefinition, + notificationSettings, + logger, + ) if err != nil { logger.Error("Failed to convert Prometheus rules to Grafana rules", "error", err) return errorToResponse(err) @@ -442,6 +465,7 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup( pauseRecordingRules bool, pauseAlertRules bool, keepOriginalRuleDefinition bool, + notificationSettings []models.NotificationSettings, 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) @@ -479,6 +503,7 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup( }, KeepOriginalRuleDefinition: util.Pointer(keepOriginalRuleDefinition), EvaluationOffset: &srv.cfg.PrometheusConversion.RuleQueryOffset, + NotificationSettings: notificationSettings, }, ) if err != nil { @@ -503,7 +528,7 @@ func parseBooleanHeader(header string, headerName string) (bool, error) { } val, err := strconv.ParseBool(header) if err != nil { - return false, errInvalidHeaderValue(headerName) + return false, errInvalidHeaderValue(headerName, errors.New("must be 'true' or 'false'")) } return val, nil } @@ -597,3 +622,23 @@ func getProvenance(ctx *contextmodel.ReqContext) models.Provenance { } return models.ProvenanceConvertedPrometheus } + +func parseNotificationSettingsHeader(ctx *contextmodel.ReqContext) ([]models.NotificationSettings, error) { + var notificationSettings []models.NotificationSettings + notificationSettingsJSON := ctx.Req.Header.Get(notificationSettingsHeader) + + if notificationSettingsJSON != "" { + var settings apimodels.AlertRuleNotificationSettings + var err error + + if err := json.Unmarshal([]byte(notificationSettingsJSON), &settings); err != nil { + return nil, errInvalidHeaderValue(notificationSettingsHeader, errors.New("invalid JSON")) + } + notificationSettings, err = validation.ValidateNotificationSettings(&settings) + if err != nil { + return nil, errInvalidHeaderValue(notificationSettingsHeader, err) + } + } + + return notificationSettings, nil +} diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index 2b493a3413c..fe8a5bf8d85 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -425,6 +426,97 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { require.NotNil(t, remaining[0].Record) require.Equal(t, targetDSUID, remaining[0].Record.TargetDatasourceUID) }) + + t.Run("sets notification settings for rules if specified", func(t *testing.T) { + srv, _, ruleStore, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + + receiver := "test-receiver" + groupBy := []string{"cluster", "pod"} + settings := apimodels.AlertRuleNotificationSettings{ + Receiver: receiver, + GroupBy: groupBy, + } + settingsJSON, err := json.Marshal(settings) + require.NoError(t, err) + rc.Req.Header.Set(notificationSettingsHeader, string(settingsJSON)) + + simpleGroup := apimodels.PrometheusRuleGroup{ + Name: "Test Group", + Interval: prommodel.Duration(1 * time.Minute), + Rules: []apimodels.PrometheusRule{ + { + Alert: "TestAlert", + Expr: "up == 0", + For: util.Pointer(prommodel.Duration(5 * time.Minute)), + Labels: map[string]string{ + "severity": "critical", + }, + }, + }, + } + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusAccepted, response.Status()) + + createdRules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + }) + require.NoError(t, err) + require.Len(t, createdRules, 1) + require.Len(t, createdRules[0].NotificationSettings, 1) + require.Equal(t, receiver, createdRules[0].NotificationSettings[0].Receiver) + require.Equal(t, groupBy, createdRules[0].NotificationSettings[0].GroupBy) + }) + + t.Run("returns error when notification settings header contains invalid JSON", func(t *testing.T) { + srv, _, _, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + + rc.Req.Header.Set(notificationSettingsHeader, "{invalid json") + + simpleGroup := apimodels.PrometheusRuleGroup{ + Name: "Test Group", + Interval: prommodel.Duration(1 * time.Minute), + Rules: []apimodels.PrometheusRule{ + { + Alert: "TestAlert", + Expr: "up == 0", + }, + }, + } + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusBadRequest, response.Status()) + require.Contains(t, string(response.Body()), "Invalid value for header X-Grafana-Alerting-Notification-Settings") + }) + + t.Run("returns error when notification settings contain invalid values", func(t *testing.T) { + srv, _, _, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + + settings := apimodels.AlertRuleNotificationSettings{ + Receiver: "", // empty receiver is invalid + } + settingsJSON, err := json.Marshal(settings) + require.NoError(t, err) + rc.Req.Header.Set(notificationSettingsHeader, string(settingsJSON)) + + simpleGroup := apimodels.PrometheusRuleGroup{ + Name: "Test Group", + Interval: prommodel.Duration(1 * time.Minute), + Rules: []apimodels.PrometheusRule{ + { + Alert: "TestAlert", + Expr: "up == 0", + }, + }, + } + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusBadRequest, response.Status()) + require.Contains(t, string(response.Body()), "Invalid value for header X-Grafana-Alerting-Notification-Settings") + }) } func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) { diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 9a221175f58..6ca437de522 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -314,6 +314,16 @@ }, "AlertRuleNotificationSettings": { "properties": { + "active_time_intervals": { + "description": "Override the times when notifications should not be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. All notifications will be suppressed unless they are sent\nat the time that matches any interval.", + "example": [ + "maintenance" + ], + "items": { + "type": "string" + }, + "type": "array" + }, "group_by": { "default": [ "alertname", @@ -341,7 +351,7 @@ "type": "string" }, "mute_time_intervals": { - "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", "example": [ "maintenance" ], @@ -368,6 +378,12 @@ }, "AlertRuleNotificationSettingsExport": { "properties": { + "active_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, "group_by": { "items": { "type": "string" @@ -2325,6 +2341,12 @@ }, "NotificationPolicyExport": { "properties": { + "active_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, "continue": { "type": "boolean" }, @@ -3710,6 +3732,12 @@ "RouteExport": { "description": "RouteExport is the provisioned file export of definitions.Route. This is needed to hide fields that aren't useable in\nprovisioning file format. An alternative would be to define a custom MarshalJSON and MarshalYAML that excludes them.", "properties": { + "active_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, "continue": { "type": "boolean" }, @@ -4889,7 +4917,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup", "type": "object" @@ -5176,7 +5203,6 @@ "type": "object" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence", "type": "object" @@ -6661,4 +6687,4 @@ } }, "swagger": "2.0" -} +} \ No newline at end of file diff --git a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go index 9758cdf0215..84e9948d7fb 100644 --- a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go +++ b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go @@ -210,6 +210,12 @@ type RouteConvertPrometheusPostRuleGroupParams struct { RecordingRulesPaused bool `json:"x-grafana-alerting-recording-rules-paused"` // in: header AlertRulesPaused bool `json:"x-grafana-alerting-alert-rules-paused"` + // in: header + TargetDatasourceUID string `json:"x-grafana-alerting-target-datasource-uid"` + // in: header + FolderUID string `json:"x-grafana-alerting-folder-uid"` + // in: header + NotificationReceiver string `json:"x-grafana-alerting-notification-receiver"` // in:body Body PrometheusRuleGroup } diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 3a75aca4379..2bba2f277cb 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -314,6 +314,16 @@ }, "AlertRuleNotificationSettings": { "properties": { + "active_time_intervals": { + "description": "Override the times when notifications should not be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. All notifications will be suppressed unless they are sent\nat the time that matches any interval.", + "example": [ + "maintenance" + ], + "items": { + "type": "string" + }, + "type": "array" + }, "group_by": { "default": [ "alertname", @@ -341,7 +351,7 @@ "type": "string" }, "mute_time_intervals": { - "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", "example": [ "maintenance" ], @@ -368,6 +378,12 @@ }, "AlertRuleNotificationSettingsExport": { "properties": { + "active_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, "group_by": { "items": { "type": "string" @@ -2325,6 +2341,12 @@ }, "NotificationPolicyExport": { "properties": { + "active_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, "continue": { "type": "boolean" }, @@ -3710,6 +3732,12 @@ "RouteExport": { "description": "RouteExport is the provisioned file export of definitions.Route. This is needed to hide fields that aren't useable in\nprovisioning file format. An alternative would be to define a custom MarshalJSON and MarshalYAML that excludes them.", "properties": { + "active_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, "continue": { "type": "boolean" }, @@ -4888,7 +4916,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup", "type": "object" @@ -5050,7 +5077,6 @@ "type": "object" }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert", "type": "object" @@ -5175,6 +5201,7 @@ "type": "object" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence", "type": "object" @@ -6644,6 +6671,21 @@ "name": "x-grafana-alerting-alert-rules-paused", "type": "boolean" }, + { + "in": "header", + "name": "x-grafana-alerting-target-datasource-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-folder-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-notification-receiver", + "type": "string" + }, { "in": "body", "name": "Body", @@ -6919,6 +6961,21 @@ "name": "x-grafana-alerting-alert-rules-paused", "type": "boolean" }, + { + "in": "header", + "name": "x-grafana-alerting-target-datasource-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-folder-uid", + "type": "string" + }, + { + "in": "header", + "name": "x-grafana-alerting-notification-receiver", + "type": "string" + }, { "in": "body", "name": "Body", @@ -9533,4 +9590,4 @@ } }, "swagger": "2.0" -} +} \ No newline at end of file diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 8996ff3e13f..a045449d24d 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1204,6 +1204,21 @@ "name": "x-grafana-alerting-alert-rules-paused", "in": "header" }, + { + "type": "string", + "name": "x-grafana-alerting-target-datasource-uid", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-folder-uid", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-notification-receiver", + "in": "header" + }, { "name": "Body", "in": "body", @@ -1479,6 +1494,21 @@ "name": "x-grafana-alerting-alert-rules-paused", "in": "header" }, + { + "type": "string", + "name": "x-grafana-alerting-target-datasource-uid", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-folder-uid", + "in": "header" + }, + { + "type": "string", + "name": "x-grafana-alerting-notification-receiver", + "in": "header" + }, { "name": "Body", "in": "body", @@ -4416,6 +4446,16 @@ "receiver" ], "properties": { + "active_time_intervals": { + "description": "Override the times when notifications should not be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. All notifications will be suppressed unless they are sent\nat the time that matches any interval.", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "maintenance" + ] + }, "group_by": { "description": "Override the labels by which incoming alerts are grouped together. For example, multiple alerts coming in for\ncluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels\nuse the special value '...' as the sole label name.\nThis effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what\nyou want, unless you have a very low alert volume or your upstream notification system performs its own grouping.\nMust include 'alertname' and 'grafana_folder' if not using '...'.", "type": "array", @@ -4443,7 +4483,7 @@ "example": "30s" }, "mute_time_intervals": { - "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", "type": "array", "items": { "type": "string" @@ -4468,6 +4508,12 @@ "type": "object", "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", "properties": { + "active_time_intervals": { + "type": "array", + "items": { + "type": "string" + } + }, "group_by": { "type": "array", "items": { @@ -6427,6 +6473,12 @@ "type": "object", "title": "NotificationPolicyExport is the provisioned file export of alerting.NotificiationPolicyV1.", "properties": { + "active_time_intervals": { + "type": "array", + "items": { + "type": "string" + } + }, "continue": { "type": "boolean" }, @@ -7811,6 +7863,12 @@ "description": "RouteExport is the provisioned file export of definitions.Route. This is needed to hide fields that aren't useable in\nprovisioning file format. An alternative would be to define a custom MarshalJSON and MarshalYAML that excludes them.", "type": "object", "properties": { + "active_time_intervals": { + "type": "array", + "items": { + "type": "string" + } + }, "continue": { "type": "boolean" }, @@ -8988,7 +9046,6 @@ } }, "alertGroups": { - "description": "AlertGroups alert groups", "type": "array", "items": { "type": "object", @@ -9150,7 +9207,6 @@ } }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "type": "array", "items": { "type": "object", @@ -9275,6 +9331,7 @@ } }, "gettableSilences": { + "description": "GettableSilences gettable silences", "type": "array", "items": { "type": "object", @@ -9566,4 +9623,4 @@ "type": "basic" } } -} +} \ No newline at end of file diff --git a/pkg/services/ngalert/api/validation/api_ruler_validation.go b/pkg/services/ngalert/api/validation/api_ruler_validation.go index 2dff4a5ab05..9fa98159979 100644 --- a/pkg/services/ngalert/api/validation/api_ruler_validation.go +++ b/pkg/services/ngalert/api/validation/api_ruler_validation.go @@ -142,7 +142,7 @@ func validateAlertingRuleFields(in *apimodels.PostableExtendedRuleNode, newRule } if in.GrafanaManagedAlert.NotificationSettings != nil { - newRule.NotificationSettings, err = validateNotificationSettings(in.GrafanaManagedAlert.NotificationSettings) + newRule.NotificationSettings, err = ValidateNotificationSettings(in.GrafanaManagedAlert.NotificationSettings) if err != nil { return ngmodels.AlertRule{}, err } @@ -391,7 +391,7 @@ func ValidateRuleGroup( return result, nil } -func validateNotificationSettings(n *apimodels.AlertRuleNotificationSettings) ([]ngmodels.NotificationSettings, error) { +func ValidateNotificationSettings(n *apimodels.AlertRuleNotificationSettings) ([]ngmodels.NotificationSettings, error) { s := ngmodels.NotificationSettings{ Receiver: n.Receiver, GroupBy: n.GroupBy, diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index e4ab9ce0a60..983f8298168 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -58,6 +58,7 @@ type Config struct { KeepOriginalRuleDefinition *bool RecordingRules RulesConfig AlertRules RulesConfig + NotificationSettings []models.NotificationSettings } // RulesConfig contains configuration that applies to either recording or alerting rules. @@ -266,6 +267,10 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom MissingSeriesEvalsToResolve: util.Pointer(1), } + if !isRecordingRule { + result.NotificationSettings = p.cfg.NotificationSettings + } + if p.cfg.KeepOriginalRuleDefinition != nil && *p.cfg.KeepOriginalRuleDefinition { result.Metadata.PrometheusStyleRule = &models.PrometheusStyleRule{ OriginalRuleDefinition: string(originalRuleDefinition), diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index 1d4668d2b27..a2d9dd29b67 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -811,6 +811,66 @@ func TestPrometheusRulesToGrafana_KeepOriginalRuleDefinition(t *testing.T) { } } +func TestPrometheusRulesToGrafana_NotificationSettings(t *testing.T) { + orgID := int64(1) + namespace := "namespace" + + promGroup := PrometheusRuleGroup{ + Name: "test-group", + Rules: []PrometheusRule{ + { + Alert: "test-alert", + Expr: "up == 0", + }, + }, + } + + testCases := []struct { + name string + notificationSettings []models.NotificationSettings + }{ + { + name: "with notification settings specified", + notificationSettings: []models.NotificationSettings{ + { + Receiver: "test-receiver", + GroupBy: []string{"alertname", "instance"}, + }, + }, + }, + { + name: "without notification settings", + notificationSettings: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cfg := Config{ + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + DefaultInterval: 1 * time.Minute, + NotificationSettings: tc.notificationSettings, + } + + converter, err := NewConverter(cfg) + require.NoError(t, err) + + grafanaGroup, err := converter.PrometheusRulesToGrafana(orgID, namespace, promGroup) + require.NoError(t, err) + require.Len(t, grafanaGroup.Rules, 1) + + if tc.notificationSettings != nil { + require.NotNil(t, grafanaGroup.Rules[0].NotificationSettings) + require.Len(t, grafanaGroup.Rules[0].NotificationSettings, len(tc.notificationSettings)) + require.Equal(t, tc.notificationSettings, grafanaGroup.Rules[0].NotificationSettings) + } else { + require.Nil(t, grafanaGroup.Rules[0].NotificationSettings) + } + }) + } +} + func TestQueryModelContainsRequiredParameters(t *testing.T) { cfg := Config{ DatasourceUID: "datasource-uid", diff --git a/pkg/tests/api/alerting/api_convert_prometheus_notification_settings_test.go b/pkg/tests/api/alerting/api_convert_prometheus_notification_settings_test.go new file mode 100644 index 00000000000..a7c2dbe7d9d --- /dev/null +++ b/pkg/tests/api/alerting/api_convert_prometheus_notification_settings_test.go @@ -0,0 +1,134 @@ +package alerting + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + prommodel "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/components/simplejson" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util" +) + +const ( + notificationSettingsHeader = "X-Grafana-Alerting-Notification-Settings" +) + +func TestIntegrationConvertPrometheusNotificationSettings(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + EnableFeatureToggles: []string{"grafanaManagedRecordingRulesDatasources", "grafanaManagedRecordingRules"}, + EnableRecordingRules: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + + ds := adminClient.CreateDatasource(t, "prometheus") + + namespace := "test-notification-settings" + namespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, namespaceUID, namespace) + + alertRuleGroup := apimodels.PrometheusRuleGroup{ + Name: "test-group-notification-settings", + Interval: prommodel.Duration(60 * time.Second), + Rules: []apimodels.PrometheusRule{ + { + Alert: "TestAlert", + Expr: "vector(1) > 0", + For: util.Pointer(prommodel.Duration(5 * time.Minute)), + Labels: map[string]string{ + "severity": "critical", + }, + Annotations: map[string]string{ + "summary": "Test alert with notification settings", + }, + }, + }, + } + + t.Run("rules should use notification settings from header", func(t *testing.T) { + receiver := "test-receiver" + + receiverSettings, err := simplejson.NewJson([]byte(`{ + "url":"https://localhost/webhook" + }`)) + require.NoError(t, err) + adminClient.EnsureReceiver(t, + apimodels.EmbeddedContactPoint{ + Name: receiver, + Type: "webhook", + Settings: receiverSettings, + }, + ) + + groupBy := []string{"alertname", "instance", "job"} + settings := apimodels.AlertRuleNotificationSettings{ + Receiver: receiver, + GroupBy: groupBy, + } + settingsJSON, err := json.Marshal(settings) + require.NoError(t, err) + + headers := map[string]string{ + notificationSettingsHeader: string(settingsJSON), + } + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, alertRuleGroup, headers) + + group, _, _ := adminClient.GetRulesGroupWithStatus(t, namespaceUID, alertRuleGroup.Name) + + require.Len(t, group.Rules, 1) + rule := group.Rules[0] + + require.NotNil(t, rule.GrafanaManagedAlert.NotificationSettings) + require.Equal(t, receiver, rule.GrafanaManagedAlert.NotificationSettings.Receiver) + require.Equal(t, groupBy, rule.GrafanaManagedAlert.NotificationSettings.GroupBy) + }) + + t.Run("invalid JSON in notification settings header should return error", func(t *testing.T) { + headers := map[string]string{ + notificationSettingsHeader: "{invalid json", + } + + _, status, body := adminClient.RawConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, alertRuleGroup, headers) + requireStatusCode(t, http.StatusBadRequest, status, body) + require.Contains(t, body, "Invalid value for header X-Grafana-Alerting-Notification-Settings") + }) + + t.Run("empty receiver in notification settings should return error", func(t *testing.T) { + settings := apimodels.AlertRuleNotificationSettings{ + Receiver: "", + GroupBy: []string{"alertname"}, + } + settingsJSON, err := json.Marshal(settings) + require.NoError(t, err) + + headers := map[string]string{ + notificationSettingsHeader: string(settingsJSON), + } + + _, status, body := adminClient.RawConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, alertRuleGroup, headers) + requireStatusCode(t, http.StatusBadRequest, status, body) + require.Contains(t, body, "Invalid value for header X-Grafana-Alerting-Notification-Settings") + }) +} diff --git a/public/api-merged.json b/public/api-merged.json index 56d3abe287b..fc5ba9b4ea6 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -12760,6 +12760,16 @@ "receiver" ], "properties": { + "active_time_intervals": { + "description": "Override the times when notifications should not be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. All notifications will be suppressed unless they are sent\nat the time that matches any interval.", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "maintenance" + ] + }, "group_by": { "description": "Override the labels by which incoming alerts are grouped together. For example, multiple alerts coming in for\ncluster=A and alertname=LatencyHigh would be batched into a single group. To aggregate by all possible labels\nuse the special value '...' as the sole label name.\nThis effectively disables aggregation entirely, passing through all alerts as-is. This is unlikely to be what\nyou want, unless you have a very low alert volume or your upstream notification system performs its own grouping.\nMust include 'alertname' and 'grafana_folder' if not using '...'.", "type": "array", @@ -12787,7 +12797,7 @@ "example": "30s" }, "mute_time_intervals": { - "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", "type": "array", "items": { "type": "string" @@ -12812,6 +12822,12 @@ "type": "object", "title": "AlertRuleNotificationSettingsExport is the provisioned export of models.NotificationSettings.", "properties": { + "active_time_intervals": { + "type": "array", + "items": { + "type": "string" + } + }, "group_by": { "type": "array", "items": { @@ -17539,6 +17555,12 @@ "type": "object", "title": "NotificationPolicyExport is the provisioned file export of alerting.NotificiationPolicyV1.", "properties": { + "active_time_intervals": { + "type": "array", + "items": { + "type": "string" + } + }, "continue": { "type": "boolean" }, @@ -20120,6 +20142,12 @@ "description": "RouteExport is the provisioned file export of definitions.Route. This is needed to hide fields that aren't useable in\nprovisioning file format. An alternative would be to define a custom MarshalJSON and MarshalYAML that excludes them.", "type": "object", "properties": { + "active_time_intervals": { + "type": "array", + "items": { + "type": "string" + } + }, "continue": { "type": "boolean" }, @@ -22864,7 +22892,6 @@ } }, "alertGroups": { - "description": "AlertGroups alert groups", "type": "array", "items": { "type": "object", @@ -23194,7 +23221,6 @@ } }, "gettableSilences": { - "description": "GettableSilences gettable silences", "type": "array", "items": { "type": "object", diff --git a/public/openapi3.json b/public/openapi3.json index aadd3405837..19db1b7b79a 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -2785,6 +2785,16 @@ }, "AlertRuleNotificationSettings": { "properties": { + "active_time_intervals": { + "description": "Override the times when notifications should not be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. All notifications will be suppressed unless they are sent\nat the time that matches any interval.", + "example": [ + "maintenance" + ], + "items": { + "type": "string" + }, + "type": "array" + }, "group_by": { "default": [ "alertname", @@ -2812,7 +2822,7 @@ "type": "string" }, "mute_time_intervals": { - "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration mute_time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", + "description": "Override the times when notifications should be muted. These must match the name of a mute time interval defined\nin the alertmanager configuration time_intervals section. When muted it will not send any notifications, but\notherwise acts normally.", "example": [ "maintenance" ], @@ -2839,6 +2849,12 @@ }, "AlertRuleNotificationSettingsExport": { "properties": { + "active_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, "group_by": { "items": { "type": "string" @@ -7566,6 +7582,12 @@ }, "NotificationPolicyExport": { "properties": { + "active_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, "continue": { "type": "boolean" }, @@ -10148,6 +10170,12 @@ "RouteExport": { "description": "RouteExport is the provisioned file export of definitions.Route. This is needed to hide fields that aren't useable in\nprovisioning file format. An alternative would be to define a custom MarshalJSON and MarshalYAML that excludes them.", "properties": { + "active_time_intervals": { + "items": { + "type": "string" + }, + "type": "array" + }, "continue": { "type": "boolean" }, @@ -12892,7 +12920,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/components/schemas/alertGroup" }, @@ -13220,7 +13247,6 @@ "type": "object" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "items": { "$ref": "#/components/schemas/gettableSilence" },