From 6f785f7269f8f8b340f6089509ab76f727895c54 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Mon, 11 Sep 2023 13:13:02 -0400 Subject: [PATCH] Alerting: Support for single rule and multi-folder rule export (#74625) --- pkg/services/ngalert/api/api_provisioning.go | 27 ++++++-- .../ngalert/api/api_provisioning_test.go | 69 +++++++++++++++++++ pkg/services/ngalert/api/tooling/api.json | 28 +++++--- .../definitions/provisioning_alert_rules.go | 11 ++- pkg/services/ngalert/api/tooling/post.json | 21 ++++-- pkg/services/ngalert/api/tooling/spec.json | 21 ++++-- .../ngalert/provisioning/alert_rules.go | 9 +-- 7 files changed, 153 insertions(+), 33 deletions(-) diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index 73b45156f18..c17c687b31e 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -66,7 +66,7 @@ type AlertRuleService interface { ReplaceRuleGroup(ctx context.Context, orgID int64, group alerting_models.AlertRuleGroup, userID int64, provenance alerting_models.Provenance) error GetAlertRuleWithFolderTitle(ctx context.Context, orgID int64, ruleUID string) (provisioning.AlertRuleWithFolderTitle, error) GetAlertRuleGroupWithFolderTitle(ctx context.Context, orgID int64, folder, group string) (alerting_models.AlertRuleGroupWithFolderTitle, error) - GetAlertGroupsWithFolderTitle(ctx context.Context, orgID int64, folderUID string) ([]alerting_models.AlertRuleGroupWithFolderTitle, error) + GetAlertGroupsWithFolderTitle(ctx context.Context, orgID int64, folderUIDs []string) ([]alerting_models.AlertRuleGroupWithFolderTitle, error) } func (srv *ProvisioningSrv) RouteGetPolicyTree(c *contextmodel.ReqContext) response.Response { @@ -391,19 +391,32 @@ func (srv *ProvisioningSrv) RouteGetAlertRuleGroup(c *contextmodel.ReqContext, f // RouteGetAlertRulesExport retrieves all alert rules in a format compatible with file provisioning. func (srv *ProvisioningSrv) RouteGetAlertRulesExport(c *contextmodel.ReqContext) response.Response { - folderUID := c.Query("folderUid") + folderUIDs := c.QueryStrings("folderUid") group := c.Query("group") - if group != "" { - if folderUID == "" { - return ErrResp(http.StatusBadRequest, nil, "group name must be specified together with folder_uid parameter") + uid := c.Query("ruleUid") + if uid != "" { + if group != "" || len(folderUIDs) > 0 { + return ErrResp(http.StatusBadRequest, errors.New("group and folder should not be specified when a single rule is requested"), "") } - return srv.RouteGetAlertRuleGroupExport(c, folderUID, group) + return srv.RouteGetAlertRuleExport(c, uid) + } + if group != "" { + if len(folderUIDs) != 1 || folderUIDs[0] == "" { + return ErrResp(http.StatusBadRequest, + fmt.Errorf("group name must be specified together with a single folder_uid parameter. Got %d", len(folderUIDs)), + "", + ) + } + return srv.RouteGetAlertRuleGroupExport(c, folderUIDs[0], group) } - groupsWithTitle, err := srv.alertRules.GetAlertGroupsWithFolderTitle(c.Req.Context(), c.OrgID, folderUID) + groupsWithTitle, err := srv.alertRules.GetAlertGroupsWithFolderTitle(c.Req.Context(), c.OrgID, folderUIDs) if err != nil { return ErrResp(http.StatusInternalServerError, err, "failed to get alert rules") } + if len(groupsWithTitle) == 0 { + return response.Empty(http.StatusNotFound) + } e, err := AlertingFileExportFromAlertRuleGroupWithFolderTitle(groupsWithTitle) if err != nil { diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 467fbc9340d..af8c1ddee77 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -932,6 +932,24 @@ func TestProvisioningApi(t *testing.T) { require.Equal(t, expectedResponse, string(response.Body())) }) + t.Run("accept multiple query parameters folder_uid", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule1", 1, "folder-uid", "groupa")) + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule2", 1, "folder-uid", "groupb")) + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule3", 1, "folder-uid2", "groupb")) + + rc.Context.Req.Header.Add("Accept", "application/json") + rc.Context.Req.Form.Set("folder_uid", "folder-uid") + rc.Context.Req.Form.Add("folder_uid", "folder-uid2") + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]},{"orgId":1,"name":"groupb","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule2","title":"rule2","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]},{"orgId":1,"name":"groupb","folder":"Folder Title2","interval":"1m","rules":[{"uid":"rule3","title":"rule3","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}` + + response := sut.RouteGetAlertRulesExport(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + t.Run("accepts parameter group", func(t *testing.T) { sut := createProvisioningSrvSut(t) insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule1", 1, "folder-uid", "groupa")) @@ -954,6 +972,57 @@ func TestProvisioningApi(t *testing.T) { rc := createTestRequestCtx() rc.Context.Req.Header.Add("Accept", "application/json") rc.Context.Req.Form.Set("group", "groupa") + rc.Context.Req.Form.Set("folderUid", "") + response := sut.RouteGetAlertRulesExport(&rc) + + require.Equal(t, 400, response.Status()) + }) + + t.Run("and fails if multiple folder UIDs are specified", func(t *testing.T) { + rc := createTestRequestCtx() + rc.Context.Req.Header.Add("Accept", "application/json") + rc.Context.Req.Form.Set("group", "groupa") + rc.Context.Req.Form.Set("folderUid", "folder-uid") + rc.Context.Req.Form.Add("folderUid", "folder-uid2") + response := sut.RouteGetAlertRulesExport(&rc) + + require.Equal(t, 400, response.Status()) + }) + }) + + t.Run("accepts parameter ruleUid", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule1", 1, "folder-uid", "groupa")) + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule2", 1, "folder-uid", "groupa")) + insertRule(t, sut, createTestAlertRuleWithFolderAndGroup("rule3", 1, "folder-uid2", "groupb")) + + rc := createTestRequestCtx() + rc.Context.Req.Header.Add("Accept", "application/json") + rc.Context.Req.Form.Set("ruleUid", "rule1") + + expectedResponse := `{"apiVersion":1,"groups":[{"orgId":1,"name":"groupa","folder":"Folder Title","interval":"1m","rules":[{"uid":"rule1","title":"rule1","condition":"A","data":[{"refId":"A","relativeTimeRange":{"from":0,"to":0},"datasourceUid":"","model":{"conditions":[{"evaluator":{"params":[3],"type":"gt"},"operator":{"type":"and"},"query":{"params":["A"]},"reducer":{"type":"last"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1==0","intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"}}],"noDataState":"OK","execErrState":"OK","for":"0s","isPaused":false}]}]}` + + response := sut.RouteGetAlertRulesExport(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + + t.Run("and fails if folderUID and group are specified", func(t *testing.T) { + rc := createTestRequestCtx() + rc.Context.Req.Header.Add("Accept", "application/json") + rc.Context.Req.Form.Set("group", "groupa") + rc.Context.Req.Form.Set("folderUid", "folder-uid") + rc.Context.Req.Form.Set("ruleUid", "rule1") + response := sut.RouteGetAlertRulesExport(&rc) + + require.Equal(t, 400, response.Status()) + }) + + t.Run("and fails if only folderUID is specified", func(t *testing.T) { + rc := createTestRequestCtx() + rc.Context.Req.Header.Add("Accept", "application/json") + rc.Context.Req.Form.Set("folderUid", "folder-uid") + rc.Context.Req.Form.Set("ruleUid", "rule2") response := sut.RouteGetAlertRulesExport(&rc) require.Equal(t, 400, response.Status()) diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index b83aac29945..bd06830e91f 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -764,6 +764,9 @@ "uid": { "description": "UID is the unique identifier of the contact point. The UID can be\nset by the user.", "example": "my_external_reference", + "maxLength": 40, + "minLength": 1, + "pattern": "^[a-zA-Z0-9\\-\\_]+$", "type": "string" } }, @@ -3854,7 +3857,6 @@ "type": "object" }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { "ForceQuery": { "type": "boolean" @@ -3890,7 +3892,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "A URL represents a parsed URL (technically, a URI reference).", + "title": "URL is a custom URL type that allows validation at configuration load time.", "type": "object" }, "Userinfo": { @@ -4070,6 +4072,7 @@ "type": "object" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -4093,7 +4096,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -4198,6 +4200,7 @@ "type": "object" }, "gettableAlert": { + "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -4253,6 +4256,7 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, @@ -4308,14 +4312,12 @@ "type": "object" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence" }, "type": "array" }, "integration": { - "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -4497,7 +4499,6 @@ "type": "object" }, "receiver": { - "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -4691,15 +4692,24 @@ "type": "string" }, { - "description": "UID of folder from which export rules", + "description": "UIDs of folders from which to export rules", "in": "query", + "items": { + "type": "string" + }, "name": "folderUid", + "type": "array" + }, + { + "description": "Name of group of rules to export. Must be specified only together with a single folder UID", + "in": "query", + "name": "group", "type": "string" }, { - "description": "Name of group of rules to export. Must be specified only together with folder UID", + "description": "UID of alert rule to export. If specified, parameters folderUid and group must be empty.", "in": "query", - "name": "group", + "name": "ruleUid", "type": "string" } ], diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go index e34e6d32134..09a54d07922 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -74,15 +74,20 @@ import ( // swagger:parameters RouteGetAlertRulesExport type AlertRulesExportParameters struct { ExportQueryParams - // UID of folder from which export rules + // UIDs of folders from which to export rules // in:query // required:false - FolderUID string `json:"folderUid"` + FolderUID []string `json:"folderUid"` - // Name of group of rules to export. Must be specified only together with folder UID + // Name of group of rules to export. Must be specified only together with a single folder UID // in:query // required: false GroupName string `json:"group"` + + // UID of alert rule to export. If specified, parameters folderUid and group must be empty. + // in:query + // required: false + RuleUID string `json:"ruleUid"` } // swagger:parameters RouteGetAlertRule RoutePutAlertRule RouteDeleteAlertRule RouteGetAlertRuleExport diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index cf7813b0e6b..5ba15d3010f 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -3857,6 +3857,7 @@ "type": "object" }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { "ForceQuery": { "type": "boolean" @@ -3892,7 +3893,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, "Userinfo": { @@ -4072,6 +4073,7 @@ "type": "object" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -4261,6 +4263,7 @@ "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -4315,7 +4318,6 @@ "type": "array" }, "integration": { - "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -6472,15 +6474,24 @@ "type": "string" }, { - "description": "UID of folder from which export rules", + "description": "UIDs of folders from which to export rules", "in": "query", + "items": { + "type": "string" + }, "name": "folderUid", + "type": "array" + }, + { + "description": "Name of group of rules to export. Must be specified only together with a single folder UID", + "in": "query", + "name": "group", "type": "string" }, { - "description": "Name of group of rules to export. Must be specified only together with folder UID", + "description": "UID of alert rule to export. If specified, parameters folderUid and group must be empty.", "in": "query", - "name": "group", + "name": "ruleUid", "type": "string" } ], diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 8b1bd684db7..6a7db20e2bc 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1879,16 +1879,25 @@ "in": "query" }, { - "type": "string", - "description": "UID of folder from which export rules", + "type": "array", + "items": { + "type": "string" + }, + "description": "UIDs of folders from which to export rules", "name": "folderUid", "in": "query" }, { "type": "string", - "description": "Name of group of rules to export. Must be specified only together with folder UID", + "description": "Name of group of rules to export. Must be specified only together with a single folder UID", "name": "group", "in": "query" + }, + { + "type": "string", + "description": "UID of alert rule to export. If specified, parameters folderUid and group must be empty.", + "name": "ruleUid", + "in": "query" } ], "responses": { @@ -6716,8 +6725,9 @@ } }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "type": "object", - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "properties": { "ForceQuery": { "type": "boolean" @@ -6931,6 +6941,7 @@ } }, "alertGroup": { + "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -7124,6 +7135,7 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -7180,7 +7192,6 @@ "$ref": "#/definitions/gettableSilences" }, "integration": { - "description": "Integration integration", "type": "object", "required": [ "name", diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 0e6c2bb6e9f..a771b349f90 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -454,13 +454,14 @@ func (service *AlertRuleService) GetAlertRuleGroupWithFolderTitle(ctx context.Co return res, nil } -// GetAlertGroupsWithFolderTitle returns all groups with folder title in the folder identified by folderUID that have at least one alert. If argument folderUID is an empty string - returns groups in all folders. -func (service *AlertRuleService) GetAlertGroupsWithFolderTitle(ctx context.Context, orgID int64, folderUID string) ([]models.AlertRuleGroupWithFolderTitle, error) { +// GetAlertGroupsWithFolderTitle returns all groups with folder title in the folders identified by folderUID that have at least one alert. If argument folderUIDs is nil or empty - returns groups in all folders. +func (service *AlertRuleService) GetAlertGroupsWithFolderTitle(ctx context.Context, orgID int64, folderUIDs []string) ([]models.AlertRuleGroupWithFolderTitle, error) { q := models.ListAlertRulesQuery{ OrgID: orgID, } - if folderUID != "" { - q.NamespaceUIDs = []string{folderUID} + + if len(folderUIDs) > 0 { + q.NamespaceUIDs = folderUIDs } ruleList, err := service.ruleStore.ListAlertRules(ctx, &q)