From 9fe523b9e6c6f11c5124f73081bb891aba1af8f5 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 13 May 2025 17:04:01 +0200 Subject: [PATCH] Alerting: API to pause all alert rules in a folder (#104674) --- pkg/services/ngalert/api/api_ruler.go | 125 +++++++--- pkg/services/ngalert/api/api_ruler_test.go | 220 +++++++++++++++++- pkg/services/ngalert/api/authorization.go | 3 +- pkg/services/ngalert/api/forking_ruler.go | 4 + .../ngalert/api/generated_base_api_ruler.go | 23 ++ pkg/services/ngalert/api/tooling/api.json | 19 +- .../api/tooling/definitions/cortex-ruler.go | 32 +++ pkg/services/ngalert/api/tooling/post.json | 62 ++++- pkg/services/ngalert/api/tooling/spec.json | 62 ++++- pkg/services/ngalert/tests/fakes/rules.go | 3 +- .../api/alerting/api_ruler_pause_test.go | 107 +++++++++ pkg/tests/api/alerting/testing.go | 32 +++ public/api-merged.json | 16 ++ public/openapi3.json | 16 ++ 14 files changed, 686 insertions(+), 38 deletions(-) create mode 100644 pkg/tests/api/alerting/api_ruler_pause_test.go diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 14d913247e7..84a582c43a5 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -452,9 +452,38 @@ func (srv RulerSrv) checkGroupLimits(group apimodels.PostableRuleGroupConfig) er // //nolint:gocyclo func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey ngmodels.AlertRuleGroupKey, rules []*ngmodels.AlertRuleWithOptionals, deletePermanently bool) response.Response { + finalChanges, amConfig, err := srv.performUpdateAlertRules(c.Req.Context(), c, groupKey, rules, deletePermanently) + + if err != nil { + if errors.As(err, &errutil.Error{}) { + return response.Err(err) + } else if errors.Is(err, ngmodels.ErrAlertRuleNotFound) { + return ErrResp(http.StatusNotFound, err, "failed to update rule group") + } else if errors.Is(err, ngmodels.ErrAlertRuleFailedValidation) || errors.Is(err, errProvisionedResource) { + return ErrResp(http.StatusBadRequest, err, "failed to update rule group") + } else if errors.Is(err, ngmodels.ErrQuotaReached) { + return ErrResp(http.StatusForbidden, err, "") + } else if errors.Is(err, store.ErrOptimisticLock) { + return ErrResp(http.StatusConflict, err, "") + } + return ErrResp(http.StatusInternalServerError, err, "failed to update rule group") + } + + if amConfig != nil { + // This isn't strictly necessary since the alertmanager config is periodically synced. + err := srv.amRefresher.ApplyConfig(c.Req.Context(), groupKey.OrgID, amConfig) + if err != nil { + srv.log.Warn("Failed to refresh Alertmanager config for org after change in notification settings", "org", c.GetOrgID(), "error", err) + } + } + + return changesToResponse(finalChanges) +} + +func (srv RulerSrv) performUpdateAlertRules(ctx context.Context, c *contextmodel.ReqContext, groupKey ngmodels.AlertRuleGroupKey, rules []*ngmodels.AlertRuleWithOptionals, deletePermanently bool) (*store.GroupDelta, *ngmodels.AlertConfiguration, error) { var finalChanges *store.GroupDelta var dbConfig *ngmodels.AlertConfiguration - err := srv.xactManager.InTransaction(c.Req.Context(), func(tranCtx context.Context) error { + err := srv.xactManager.InTransaction(ctx, func(tranCtx context.Context) error { id, _ := c.GetInternalID() userNamespace := c.GetIdentityType() @@ -471,18 +500,18 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey return nil } - err = srv.authz.AuthorizeRuleChanges(c.Req.Context(), c.SignedInUser, groupChanges) + err = srv.authz.AuthorizeRuleChanges(tranCtx, c.SignedInUser, groupChanges) if err != nil { return err } - if err := validateQueries(c.Req.Context(), groupChanges, srv.conditionValidator, c.SignedInUser); err != nil { + if err := validateQueries(tranCtx, groupChanges, srv.conditionValidator, c.SignedInUser); err != nil { return err } newOrUpdatedNotificationSettings := groupChanges.NewOrUpdatedNotificationSettings() if len(newOrUpdatedNotificationSettings) > 0 { - dbConfig, err = srv.amConfigStore.GetLatestAlertmanagerConfiguration(c.Req.Context(), groupChanges.GroupKey.OrgID) + dbConfig, err = srv.amConfigStore.GetLatestAlertmanagerConfiguration(tranCtx, groupChanges.GroupKey.OrgID) if err != nil { return fmt.Errorf("failed to get latest configuration: %w", err) } @@ -498,7 +527,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey } } - if err := verifyProvisionedRulesNotAffected(c.Req.Context(), srv.provenanceStore, c.GetOrgID(), groupChanges); err != nil { + if err := verifyProvisionedRulesNotAffected(tranCtx, srv.provenanceStore, c.GetOrgID(), groupChanges); err != nil { return err } @@ -568,29 +597,10 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey }) if err != nil { - if errors.As(err, &errutil.Error{}) { - return response.Err(err) - } else if errors.Is(err, ngmodels.ErrAlertRuleNotFound) { - return ErrResp(http.StatusNotFound, err, "failed to update rule group") - } else if errors.Is(err, ngmodels.ErrAlertRuleFailedValidation) || errors.Is(err, errProvisionedResource) { - return ErrResp(http.StatusBadRequest, err, "failed to update rule group") - } else if errors.Is(err, ngmodels.ErrQuotaReached) { - return ErrResp(http.StatusForbidden, err, "") - } else if errors.Is(err, store.ErrOptimisticLock) { - return ErrResp(http.StatusConflict, err, "") - } - return ErrResp(http.StatusInternalServerError, err, "failed to update rule group") + return nil, nil, err } - if dbConfig != nil { - // This isn't strictly necessary since the alertmanager config is periodically synced. - err := srv.amRefresher.ApplyConfig(c.Req.Context(), groupKey.OrgID, dbConfig) - if err != nil { - srv.log.Warn("Failed to refresh Alertmanager config for org after change in notification settings", "org", c.GetOrgID(), "error", err) - } - } - - return changesToResponse(finalChanges) + return finalChanges, dbConfig, nil } func changesToResponse(finalChanges *store.GroupDelta) response.Response { @@ -811,6 +821,69 @@ func (srv RulerSrv) searchAuthorizedAlertRules(ctx context.Context, q authorized return byGroupKey, totalGroups, nil } +// RouteUpdateNamespaceRules updates all alert rules in a namespace. +func (srv RulerSrv) RouteUpdateNamespaceRules(c *contextmodel.ReqContext, body apimodels.UpdateNamespaceRulesRequest, namespaceUID string) response.Response { + if body == (apimodels.UpdateNamespaceRulesRequest{}) { + return ErrResp(http.StatusBadRequest, errors.New("missing request body"), "") + } + + namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser) + if err != nil { + return toNamespaceErrorResponse(err) + } + + ruleGroups, _, err := srv.searchAuthorizedAlertRules(c.Req.Context(), authorizedRuleGroupQuery{ + User: c.SignedInUser, + NamespaceUIDs: []string{namespace.UID}, + }) + if err != nil { + return errorToResponse(err) + } + + if len(ruleGroups) == 0 { + return response.JSON(http.StatusAccepted, apimodels.UpdateNamespaceRulesResponse{ + Message: "no rules to update in namespace", + }) + } + + err = srv.xactManager.InTransaction(c.Req.Context(), func(ctx context.Context) error { + for groupKey, rules := range ruleGroups { + rulesToUpdate := make([]*ngmodels.AlertRuleWithOptionals, 0, len(rules)) + + for _, rule := range rules { + r := ngmodels.AlertRuleWithOptionals{ + AlertRule: *rule, + HasPause: true, + HasEditorSettings: true, + } + if body.IsPaused != nil { + paused := *body.IsPaused + r.IsPaused = paused + } + + rulesToUpdate = append(rulesToUpdate, &r) + } + _, _, err := srv.performUpdateAlertRules(ctx, c, groupKey, rulesToUpdate, false) + if errors.Is(err, errProvisionedResource) { + continue + } + if err != nil { + return err + } + } + + return nil + }) + + if err != nil { + return errorToResponse(err) + } + + return response.JSON(http.StatusAccepted, apimodels.UpdateNamespaceRulesResponse{ + Message: "rules updated successfully", + }) +} + // getUserUIDmaping returns a UserUID->UserInfo mapping from the UpdatedBy users in the RulesGroup func (srv RulerSrv) getUserUIDmapping(ctx context.Context, rules []*ngmodels.AlertRule) map[ngmodels.UserUID]*apimodels.UserInfo { mapping := map[ngmodels.UserUID]*apimodels.UserInfo{ diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index 75d4fc0b0bb..43b13ccf9a1 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -971,11 +971,12 @@ func createService(store *fakes.RuleStore, _userService *usertest.FakeUserServic cfg: &setting.UnifiedAlertingSettings{ BaseInterval: 10 * time.Second, }, - authz: accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures())), - amConfigStore: &fakeAMRefresher{}, - amRefresher: &fakeAMRefresher{}, - featureManager: featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRules), - userService: userService, + authz: accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures())), + amConfigStore: &fakeAMRefresher{}, + amRefresher: &fakeAMRefresher{}, + featureManager: featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRules), + userService: userService, + conditionValidator: &recordingConditionValidator{}, } } @@ -1027,6 +1028,7 @@ func createPermissionsForRules(rules []*models.AlertRule, orgID int64) map[int64 scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(rule.NamespaceUID) permissions[dashboards.ActionFoldersRead] = append(permissions[dashboards.ActionFoldersRead], scope) permissions[ac.ActionAlertingRuleRead] = append(permissions[ac.ActionAlertingRuleRead], scope) + permissions[ac.ActionAlertingRuleUpdate] = append(permissions[ac.ActionAlertingRuleUpdate], scope) ns[rule.NamespaceUID] = struct{}{} } for _, query := range rule.Data { @@ -1049,3 +1051,211 @@ func createPermissionsForRulesWithoutDS(rules []*models.AlertRule, orgID int64) } return map[int64]map[string][]string{orgID: permissions} } + +func TestRouteUpdateNamespaceRules(t *testing.T) { + orgID := rand.Int63() + folder := randFolder() + gen := models.RuleGen.With( + models.RuleGen.WithOrgID(orgID), + models.RuleGen.WithNamespaceUID(folder.UID), + ) + + initFakeRuleStore := func(t *testing.T) *fakes.RuleStore { + ruleStore := fakes.NewRuleStore(t) + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + return ruleStore + } + + getRecordedUpdatedRules := func(ruleStore *fakes.RuleStore) []models.UpdateRule { + raw := ruleStore.GetRecordedCommands(func(cmd any) (any, bool) { + if u, ok := cmd.([]models.UpdateRule); ok { + return u, true + } + return nil, false + }) + + updates := []models.UpdateRule{} + for _, cmd := range raw { + updates = append(updates, cmd.([]models.UpdateRule)...) + } + return updates + } + + t.Run("should pause all non-provisioned rules in namespace", func(t *testing.T) { + ruleStore := initFakeRuleStore(t) + provisioningStore := fakes.NewFakeProvisioningStore() + + // Create 3 types of rules: paused, provisioned paused, and unpaused + pausedRules := gen.With(gen.WithGroupPrefix("paused-"), gen.WithIsPaused(true)).GenerateManyRef(2) + + unpausedRules := gen.With(gen.WithGroupPrefix("unpaused-"), gen.WithIsPaused(false)).GenerateManyRef(1) + + provisionedRules := gen.With( + gen.WithGroupPrefix("provisioned-"), + gen.WithIsPaused(false), + ).GenerateManyRef(3) + for _, r := range provisionedRules { + err := provisioningStore.SetProvenance(context.Background(), r, orgID, models.ProvenanceAPI) + require.NoError(t, err) + } + + ruleStore.PutRule(context.Background(), unpausedRules...) + ruleStore.PutRule(context.Background(), provisionedRules...) + ruleStore.PutRule(context.Background(), pausedRules...) + + allRules := append(append(unpausedRules, provisionedRules...), pausedRules...) + permissions := createPermissionsForRules(allRules, orgID) + requestCtx := createRequestContextWithPerms(orgID, permissions, nil) + + svc := createServiceWithProvenanceStore(ruleStore, provisioningStore) + response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(true), + }, folder.UID) + + require.Equal(t, http.StatusAccepted, response.Status()) + result := &apimodels.UpdateNamespaceRulesResponse{} + require.NoError(t, json.Unmarshal(response.Body(), result)) + require.Equal(t, "rules updated successfully", result.Message) + + updatedRules := getRecordedUpdatedRules(ruleStore) + require.Len(t, updatedRules, len(unpausedRules)) + + for _, update := range updatedRules { + require.True(t, update.New.IsPaused) + } + }) + + t.Run("should unpause all non-provisioned rules in namespace", func(t *testing.T) { + ruleStore := initFakeRuleStore(t) + provisioningStore := fakes.NewFakeProvisioningStore() + + // Create 3 types of rules: paused, provisioned paused, and unpaused + pausedRules := gen.With(gen.WithGroupPrefix("paused-"), gen.WithIsPaused(true)).GenerateManyRef(4) + + unpausedRules := gen.With(gen.WithGroupPrefix("unpaused-"), gen.WithIsPaused(false)).GenerateManyRef(3) + + provisionedRules := gen.With( + gen.WithGroupPrefix("provisioned-"), + gen.WithIsPaused(false), + ).GenerateManyRef(2) + for _, r := range provisionedRules { + err := provisioningStore.SetProvenance(context.Background(), r, orgID, models.ProvenanceAPI) + require.NoError(t, err) + } + + ruleStore.PutRule(context.Background(), pausedRules...) + ruleStore.PutRule(context.Background(), provisionedRules...) + ruleStore.PutRule(context.Background(), unpausedRules...) + + allRules := append(append(pausedRules, provisionedRules...), unpausedRules...) + permissions := createPermissionsForRules(allRules, orgID) + requestCtx := createRequestContextWithPerms(orgID, permissions, nil) + + svc := createServiceWithProvenanceStore(ruleStore, provisioningStore) + response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(false), + }, folder.UID) + + require.Equal(t, http.StatusAccepted, response.Status()) + result := &apimodels.UpdateNamespaceRulesResponse{} + require.NoError(t, json.Unmarshal(response.Body(), result)) + require.Equal(t, "rules updated successfully", result.Message) + + updatedRules := getRecordedUpdatedRules(ruleStore) + require.Len(t, updatedRules, len(pausedRules)) + + // all rules are now unpaused + for _, update := range updatedRules { + require.False(t, update.New.IsPaused) + } + }) + + t.Run("returns 202 when no rules need updating", func(t *testing.T) { + ruleStore := initFakeRuleStore(t) + provisioningStore := fakes.NewFakeProvisioningStore() + + // Create already unpaused rules + rules := gen.With(gen.WithGroupPrefix("paused-"), gen.WithIsPaused(false)).GenerateManyRef(5) + + ruleStore.PutRule(context.Background(), rules...) + + permissions := createPermissionsForRules(rules, orgID) + requestCtx := createRequestContextWithPerms(orgID, permissions, nil) + + // Create request to unpause rules (they are already unpaused) + svc := createServiceWithProvenanceStore(ruleStore, provisioningStore) + response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(false), + }, folder.UID) + + require.Equal(t, http.StatusAccepted, response.Status()) + result := &apimodels.UpdateNamespaceRulesResponse{} + require.NoError(t, json.Unmarshal(response.Body(), result)) + require.Equal(t, "rules updated successfully", result.Message) + + // Verify no rules were updated + updatedRules := getRecordedUpdatedRules(ruleStore) + require.Empty(t, updatedRules) + }) + + t.Run("should return 202 with 'no rules to update in namespace' when namespace is empty", func(t *testing.T) { + ruleStore := initFakeRuleStore(t) + provisioningStore := fakes.NewFakeProvisioningStore() + + requestCtx := createRequestContextWithPerms(orgID, map[int64]map[string][]string{}, nil) + + svc := createServiceWithProvenanceStore(ruleStore, provisioningStore) + response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(true), + }, folder.UID) + + require.Equal(t, http.StatusAccepted, response.Status()) + result := &apimodels.UpdateNamespaceRulesResponse{} + require.NoError(t, json.Unmarshal(response.Body(), result)) + require.Equal(t, "no rules to update in namespace", result.Message) + + updatedRules := getRecordedUpdatedRules(ruleStore) + require.Empty(t, updatedRules) + }) + + t.Run("should handle folder not found", func(t *testing.T) { + ruleStore := initFakeRuleStore(t) + provisioningStore := fakes.NewFakeProvisioningStore() + + requestCtx := createRequestContextWithPerms(orgID, map[int64]map[string][]string{}, nil) + + svc := createServiceWithProvenanceStore(ruleStore, provisioningStore) + response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(true), + }, "non-existent-folder-uid") + + require.Equal(t, http.StatusNotFound, response.Status()) + + updatedRules := getRecordedUpdatedRules(ruleStore) + require.Empty(t, updatedRules) + }) + + t.Run("should return 202 with no updates when the user does not see any rules", func(t *testing.T) { + ruleStore := initFakeRuleStore(t) + provisioningStore := fakes.NewFakeProvisioningStore() + + rules := gen.GenerateManyRef(2) + ruleStore.PutRule(context.Background(), rules...) + + permissions := map[int64]map[string][]string{orgID: {}} + requestCtx := createRequestContextWithPerms(orgID, permissions, nil) + + svc := createServiceWithProvenanceStore(ruleStore, provisioningStore) + response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(true), + }, folder.UID) + + require.Equal(t, http.StatusAccepted, response.Status()) + result := &apimodels.UpdateNamespaceRulesResponse{} + require.NoError(t, json.Unmarshal(response.Body(), result)) + require.Equal(t, "no rules to update in namespace", result.Message) + + updatedRules := getRecordedUpdatedRules(ruleStore) + require.Empty(t, updatedRules) + }) +} diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 93e3b746e60..78a6bd71b62 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -52,7 +52,8 @@ func (api *API) authorize(method, path string) web.Handler { eval = ac.EvalAll(ac.EvalPermission(ac.ActionAlertingRuleRead, scope), ac.EvalPermission(dashboards.ActionFoldersRead, scope), ) - case http.MethodPost + "/api/ruler/grafana/api/v1/rules/{Namespace}": + case http.MethodPost + "/api/ruler/grafana/api/v1/rules/{Namespace}", + http.MethodPatch + "/api/ruler/grafana/api/v1/rules/{Namespace}": scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":Namespace")) // more granular permissions are enforced by the handler via "authorizeRuleChanges" eval = ac.EvalAll( diff --git a/pkg/services/ngalert/api/forking_ruler.go b/pkg/services/ngalert/api/forking_ruler.go index 8638ad7b57d..703b19fefe6 100644 --- a/pkg/services/ngalert/api/forking_ruler.go +++ b/pkg/services/ngalert/api/forking_ruler.go @@ -132,3 +132,7 @@ func (f *RulerApiHandler) handleRouteGetRuleVersionsByUID(ctx *contextmodel.ReqC func (f *RulerApiHandler) handleRouteDeleteRuleFromTrashByGUID(ctx *contextmodel.ReqContext, ruleGUID string) response.Response { return f.GrafanaRuler.RouteDeleteAlertRuleFromTrashByGUID(ctx, ruleGUID) } + +func (f *RulerApiHandler) handleRouteUpdateNamespaceRules(ctx *contextmodel.ReqContext, body apimodels.UpdateNamespaceRulesRequest, namespace string) response.Response { + return f.GrafanaRuler.RouteUpdateNamespaceRules(ctx, body, namespace) +} diff --git a/pkg/services/ngalert/api/generated_base_api_ruler.go b/pkg/services/ngalert/api/generated_base_api_ruler.go index d957256723e..dd918318392 100644 --- a/pkg/services/ngalert/api/generated_base_api_ruler.go +++ b/pkg/services/ngalert/api/generated_base_api_ruler.go @@ -37,6 +37,7 @@ type RulerApi interface { RoutePostNameGrafanaRulesConfig(*contextmodel.ReqContext) response.Response RoutePostNameRulesConfig(*contextmodel.ReqContext) response.Response RoutePostRulesGroupForExport(*contextmodel.ReqContext) response.Response + RouteUpdateNamespaceRules(*contextmodel.ReqContext) response.Response } func (f *RulerApiHandler) RouteDeleteGrafanaRuleGroupConfig(ctx *contextmodel.ReqContext) response.Response { @@ -144,6 +145,16 @@ func (f *RulerApiHandler) RoutePostRulesGroupForExport(ctx *contextmodel.ReqCont } return f.handleRoutePostRulesGroupForExport(ctx, conf, namespaceParam) } +func (f *RulerApiHandler) RouteUpdateNamespaceRules(ctx *contextmodel.ReqContext) response.Response { + // Parse Path Parameters + namespaceParam := web.Params(ctx.Req)[":Namespace"] + // Parse Request Body + conf := apimodels.UpdateNamespaceRulesRequest{} + if err := web.Bind(ctx.Req, &conf); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + return f.handleRouteUpdateNamespaceRules(ctx, conf, namespaceParam) +} func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) { api.RouteRegister.Group("", func(group routing.RouteRegister) { @@ -351,5 +362,17 @@ func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) { m, ), ) + group.Patch( + toMacaronPath("/api/ruler/grafana/api/v1/rules/{Namespace}"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodPatch, "/api/ruler/grafana/api/v1/rules/{Namespace}"), + metrics.Instrument( + http.MethodPatch, + "/api/ruler/grafana/api/v1/rules/{Namespace}", + api.Hooks.Wrap(srv.RouteUpdateNamespaceRules), + m, + ), + ) }, middleware.ReqSignedIn) } diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 6ca437de522..1c2ba6ab7b4 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -4636,7 +4636,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\nThe Host field contains the host and port subcomponents of the URL.\nWhen the port is present, it is separated from the host with a colon.\nWhen the host is an IPv6 address, it must be enclosed in square brackets:\n\"[fe80::1]:80\". The [net.JoinHostPort] function combines a host and port\ninto a string suitable for the Host field, adding square brackets to\nthe host when necessary.\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 [URL.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" @@ -4672,7 +4671,23 @@ "$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" + }, + "UpdateNamespaceRulesRequest": { + "properties": { + "is_paused": { + "type": "boolean" + } + }, + "type": "object" + }, + "UpdateNamespaceRulesResponse": { + "properties": { + "message": { + "type": "string" + } + }, "type": "object" }, "UpdateRuleGroupResponse": { diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index 8fbad34dacf..0bb57a9de82 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -97,6 +97,19 @@ import ( // 403: ForbiddenError // +// swagger:route PATCH /ruler/grafana/api/v1/rules/{Namespace} ruler RouteUpdateNamespaceRules +// +// Update all rules in a namespace +// +// Consumes: +// - application/json +// +// Responses: +// 202: UpdateNamespaceRulesResponse +// 403: ForbiddenError +// 404: NotFound. +// + // swagger:route POST /ruler/grafana/api/v1/rules/{Namespace}/export ruler RoutePostRulesGroupForExport // // Converts submitted rule group to provisioning format @@ -686,3 +699,22 @@ type UpdateRuleGroupResponse struct { Updated []string `json:"updated,omitempty"` Deleted []string `json:"deleted,omitempty"` } + +// swagger:parameters RouteUpdateNamespaceRules +type UpdateNamespaceRulesParams struct { + // The UID of the rule folder + // in:path + Namespace string + // in:body + Body UpdateNamespaceRulesRequest +} + +// swagger:model +type UpdateNamespaceRulesRequest struct { + IsPaused *bool `json:"is_paused"` +} + +// swagger:model +type UpdateNamespaceRulesResponse struct { + Message string `json:"message"` +} diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 2bba2f277cb..edc562c0ae0 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -4636,6 +4636,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\nThe Host field contains the host and port subcomponents of the URL.\nWhen the port is present, it is separated from the host with a colon.\nWhen the host is an IPv6 address, it must be enclosed in square brackets:\n\"[fe80::1]:80\". The [net.JoinHostPort] function combines a host and port\ninto a string suitable for the Host field, adding square brackets to\nthe host when necessary.\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 [URL.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" @@ -4671,7 +4672,23 @@ "$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" + }, + "UpdateNamespaceRulesRequest": { + "properties": { + "is_paused": { + "type": "boolean" + } + }, + "type": "object" + }, + "UpdateNamespaceRulesResponse": { + "properties": { + "message": { + "type": "string" + } + }, "type": "object" }, "UpdateRuleGroupResponse": { @@ -7473,6 +7490,49 @@ "ruler" ] }, + "patch": { + "consumes": [ + "application/json" + ], + "description": "Update all rules in a namespace", + "operationId": "RouteUpdateNamespaceRules", + "parameters": [ + { + "description": "The UID of the rule folder", + "in": "path", + "name": "Namespace", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/UpdateNamespaceRulesRequest" + } + } + ], + "responses": { + "202": { + "description": "UpdateNamespaceRulesResponse", + "schema": { + "$ref": "#/definitions/UpdateNamespaceRulesResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "$ref": "#/responses/NotFound." + } + }, + "tags": [ + "ruler" + ] + }, "post": { "consumes": [ "application/json", diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index a045449d24d..8d4ba4c5e03 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -2071,6 +2071,49 @@ } } } + }, + "patch": { + "description": "Update all rules in a namespace", + "consumes": [ + "application/json" + ], + "tags": [ + "ruler" + ], + "operationId": "RouteUpdateNamespaceRules", + "parameters": [ + { + "type": "string", + "description": "The UID of the rule folder", + "name": "Namespace", + "in": "path", + "required": true + }, + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateNamespaceRulesRequest" + } + } + ], + "responses": { + "202": { + "description": "UpdateNamespaceRulesResponse", + "schema": { + "$ref": "#/definitions/UpdateNamespaceRulesResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + }, + "404": { + "$ref": "#/responses/NotFound." + } + } } }, "/ruler/grafana/api/v1/rules/{Namespace}/export": { @@ -8766,8 +8809,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\nThe Host field contains the host and port subcomponents of the URL.\nWhen the port is present, it is separated from the host with a colon.\nWhen the host is an IPv6 address, it must be enclosed in square brackets:\n\"[fe80::1]:80\". The [net.JoinHostPort] function combines a host and port\ninto a string suitable for the Host field, adding square brackets to\nthe host when necessary.\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 [URL.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" @@ -8804,6 +8848,22 @@ } } }, + "UpdateNamespaceRulesRequest": { + "type": "object", + "properties": { + "is_paused": { + "type": "boolean" + } + } + }, + "UpdateNamespaceRulesResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "UpdateRuleGroupResponse": { "type": "object", "properties": { diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index cc5b70bf0b3..4ab941a379d 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -2,7 +2,6 @@ package fakes import ( "context" - "fmt" "math/rand" "slices" "sync" @@ -270,7 +269,7 @@ func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64 return folder, nil } } - return nil, fmt.Errorf("not found") + return nil, dashboards.ErrFolderNotFound } func (f *RuleStore) GetOrCreateNamespaceByTitle(ctx context.Context, title string, orgID int64, user identity.Requester, parentUID string) (*folder.FolderReference, error) { diff --git a/pkg/tests/api/alerting/api_ruler_pause_test.go b/pkg/tests/api/alerting/api_ruler_pause_test.go new file mode 100644 index 00000000000..3ceb72cce55 --- /dev/null +++ b/pkg/tests/api/alerting/api_ruler_pause_test.go @@ -0,0 +1,107 @@ +package alerting + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + 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" +) + +func TestIntegrationAlertRulePauseNamespace(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database + dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p) + + // Create a user to make authenticated requests + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "admin", + Login: "admin", + }) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleViewer), + Password: "viewer", + Login: "viewer", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin") + viewerClient := newAlertingApiClient(grafanaListedAddr, "viewer", "viewer") + + // Create the folder we'll save our alerts to + folderUID := util.GenerateShortUID() + apiClient.CreateFolder(t, folderUID, "folder1") + + // Create multiple rule groups in the folder + group1 := generateAlertRuleGroup(2, alertRuleGen()) + apiClient.PostRulesGroup(t, folderUID, &group1, false) + group2 := generateAlertRuleGroup(3, alertRuleGen()) + apiClient.PostRulesGroup(t, folderUID, &group2, false) + + t.Run("pause all rules in namespace", func(t *testing.T) { + req := &apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(true), + } + response, status, _ := apiClient.UpdateNamespaceRules(t, folderUID, req) + require.Equal(t, http.StatusAccepted, status) + assert.Equal(t, "rules updated successfully", response.Message) + + // Verify all rules are now paused + allRules, status, _ := apiClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + for _, group := range allRules[folderUID] { + for _, rule := range group.Rules { + assert.True(t, rule.GrafanaManagedAlert.IsPaused, "Rule should be paused") + } + } + }) + + t.Run("unpause all rules in namespace", func(t *testing.T) { + req := &apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(false), + } + response, status, _ := apiClient.UpdateNamespaceRules(t, folderUID, req) + require.Equal(t, http.StatusAccepted, status) + assert.Equal(t, "rules updated successfully", response.Message) + + // Verify all rules are now unpaused + allRules, status, _ := apiClient.GetAllRulesWithStatus(t) + require.Equal(t, http.StatusOK, status) + for _, group := range allRules[folderUID] { + for _, rule := range group.Rules { + assert.False(t, rule.GrafanaManagedAlert.IsPaused, "Rule should be unpaused") + } + } + }) + + t.Run("returns 403 for non-existent folder", func(t *testing.T) { + req := &apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(false), + } + _, status, _ := apiClient.UpdateNamespaceRules(t, "non-existent-folder", req) + require.Equal(t, http.StatusForbidden, status) + }) + + t.Run("viewer cannot pause rules", func(t *testing.T) { + req := &apimodels.UpdateNamespaceRulesRequest{ + IsPaused: util.Pointer(false), + } + _, status, _ := viewerClient.UpdateNamespaceRules(t, folderUID, req) + require.Equal(t, http.StatusForbidden, status) + }) +} diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index f79fe7f3ec6..f0d57cafff7 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -1280,6 +1280,38 @@ func (a apiClient) RawConvertPrometheusDeleteNamespace(t *testing.T, namespaceTi return sendRequestJSON[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted) } +func (a apiClient) UpdateNamespaceRules(t *testing.T, folder string, body *apimodels.UpdateNamespaceRulesRequest) (apimodels.UpdateNamespaceRulesResponse, int, string) { + t.Helper() + + client := &http.Client{} + + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err := enc.Encode(body) + require.NoError(t, err) + + u := fmt.Sprintf("%s/api/ruler/grafana/api/v1/rules/%s", a.url, folder) + + req, err := http.NewRequest(http.MethodPatch, u, &buf) + req.Header.Set("Content-Type", "application/json") + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { + _ = resp.Body.Close() + }() + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var m apimodels.UpdateNamespaceRulesResponse + if resp.StatusCode == http.StatusAccepted { + require.NoError(t, json.Unmarshal(b, &m)) + } + return m, resp.StatusCode, string(b) +} + func sendRequestRaw(t *testing.T, req *http.Request) ([]byte, int, error) { t.Helper() client := &http.Client{} diff --git a/public/api-merged.json b/public/api-merged.json index fc5ba9b4ea6..415f3b65061 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -22205,6 +22205,22 @@ } } }, + "UpdateNamespaceRulesRequest": { + "type": "object", + "properties": { + "is_paused": { + "type": "boolean" + } + } + }, + "UpdateNamespaceRulesResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "UpdateOrgAddressForm": { "type": "object", "properties": { diff --git a/public/openapi3.json b/public/openapi3.json index 19db1b7b79a..c41c4b57bc0 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -12233,6 +12233,22 @@ }, "type": "object" }, + "UpdateNamespaceRulesRequest": { + "properties": { + "is_paused": { + "type": "boolean" + } + }, + "type": "object" + }, + "UpdateNamespaceRulesResponse": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + }, "UpdateOrgAddressForm": { "properties": { "address1": {