diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 954a1dd2a40..74b9d2ade0a 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -42,52 +42,82 @@ var ( errQuotaReached = errors.New("quota has been exceeded") ) -func (srv RulerSrv) RouteDeleteNamespaceRulesConfig(c *models.ReqContext) response.Response { +// RouteDeleteAlertRules deletes all alert rules user is authorized to access in the namespace (request parameter :Namespace) +// or, if specified, a group of rules (request parameter :Groupname) in the namespace +func (srv RulerSrv) RouteDeleteAlertRules(c *models.ReqContext) response.Response { namespaceTitle := web.Params(c.Req)[":Namespace"] namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgId, c.SignedInUser, true) if err != nil { return toNamespaceErrorResponse(err) } + var loggerCtx = []interface{}{ + "namespace", + namespace.Title, + } + var ruleGroup *string + if group, ok := web.Params(c.Req)[":Groupname"]; ok { + ruleGroup = &group + loggerCtx = append(loggerCtx, "group", group) + } + logger := srv.log.New(loggerCtx...) - uids, err := srv.store.DeleteNamespaceAlertRules(c.Req.Context(), c.SignedInUser.OrgId, namespace.Uid) - if err != nil { - return ErrResp(http.StatusInternalServerError, err, "failed to delete namespace alert rules") + hasAccess := func(evaluator accesscontrol.Evaluator) bool { + return accesscontrol.HasAccess(srv.ac, c)(accesscontrol.ReqOrgAdminOrEditor, evaluator) } - for _, uid := range uids { - srv.scheduleService.DeleteAlertRule(ngmodels.AlertRuleKey{ - OrgID: c.SignedInUser.OrgId, - UID: uid, - }) - } + var canDelete, cannotDelete []string + err = srv.xactManager.InTransaction(c.Req.Context(), func(ctx context.Context) error { + q := ngmodels.GetAlertRulesQuery{ + OrgID: c.SignedInUser.OrgId, + NamespaceUID: namespace.Uid, + RuleGroup: ruleGroup, + } + if err = srv.store.GetAlertRules(ctx, &q); err != nil { + return err + } - return response.JSON(http.StatusAccepted, util.DynMap{"message": "namespace rules deleted"}) -} + if len(q.Result) == 0 { + logger.Debug("no alert rules to delete from namespace/group") + return nil + } -func (srv RulerSrv) RouteDeleteRuleGroupConfig(c *models.ReqContext) response.Response { - namespaceTitle := web.Params(c.Req)[":Namespace"] - namespace, err := srv.store.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.OrgId, c.SignedInUser, true) - if err != nil { - return toNamespaceErrorResponse(err) - } - ruleGroup := web.Params(c.Req)[":Groupname"] - uids, err := srv.store.DeleteRuleGroupAlertRules(c.Req.Context(), c.SignedInUser.OrgId, namespace.Uid, ruleGroup) + canDelete = make([]string, 0, len(q.Result)) + for _, rule := range q.Result { + if authorizeDatasourceAccessForRule(rule, hasAccess) { + canDelete = append(canDelete, rule.UID) + continue + } + cannotDelete = append(cannotDelete, rule.UID) + } + + if len(canDelete) == 0 { + return fmt.Errorf("%w to delete rules because user is not authorized to access data sources used by the rules", ErrAuthorization) + } + + if len(cannotDelete) > 0 { + logger.Info("user cannot delete one or many alert rules because it does not have access to data sources. Those rules will be skipped", "expected", len(q.Result), "authorized", len(canDelete), "unauthorized", cannotDelete) + } + + return srv.store.DeleteAlertRulesByUID(ctx, c.SignedInUser.OrgId, canDelete...) + }) if err != nil { - if errors.Is(err, ngmodels.ErrRuleGroupNamespaceNotFound) { - return ErrResp(http.StatusNotFound, err, "failed to delete rule group") + if errors.Is(err, ErrAuthorization) { + return ErrResp(http.StatusUnauthorized, err, "") } return ErrResp(http.StatusInternalServerError, err, "failed to delete rule group") } - for _, uid := range uids { + logger.Debug("rules have been deleted from the store. updating scheduler") + + for _, uid := range canDelete { srv.scheduleService.DeleteAlertRule(ngmodels.AlertRuleKey{ OrgID: c.SignedInUser.OrgId, UID: uid, }) } - return response.JSON(http.StatusAccepted, util.DynMap{"message": "rule group deleted"}) + return response.JSON(http.StatusAccepted, util.DynMap{"message": "rules deleted"}) } func (srv RulerSrv) RouteGetNamespaceRulesConfig(c *models.ReqContext) response.Response { diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index 99d2375d927..2a9eac51b4a 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -4,15 +4,24 @@ import ( "context" "errors" "math/rand" + "net/http" "testing" "time" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/log" models2 "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + acMock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" ) func TestCalculateChanges(t *testing.T) { @@ -255,6 +264,266 @@ func TestCalculateChanges(t *testing.T) { }) } +func TestRouteDeleteAlertRules(t *testing.T) { + createService := func(ac *acMock.Mock, store *store.FakeRuleStore, scheduler schedule.ScheduleService) *RulerSrv { + return &RulerSrv{ + xactManager: store, + store: store, + DatasourceCache: nil, + QuotaService: nil, + scheduleService: scheduler, + log: log.New("test"), + cfg: nil, + ac: ac, + } + } + + getRecordedCommand := func(ruleStore *store.FakeRuleStore) []store.GenericRecordedQuery { + results := ruleStore.GetRecordedCommands(func(cmd interface{}) (interface{}, bool) { + c, ok := cmd.(store.GenericRecordedQuery) + if !ok || c.Name != "DeleteAlertRulesByUID" { + return nil, false + } + return c, ok + }) + var result []store.GenericRecordedQuery + for _, cmd := range results { + result = append(result, cmd.(store.GenericRecordedQuery)) + } + return result + } + + assertRulesDeleted := func(t *testing.T, expectedRules []*models.AlertRule, ruleStore *store.FakeRuleStore, scheduler *schedule.FakeScheduleService) { + deleteCommands := getRecordedCommand(ruleStore) + require.Len(t, deleteCommands, 1) + cmd := deleteCommands[0] + actualUIDs := cmd.Params[1].([]string) + require.Len(t, actualUIDs, len(expectedRules)) + for _, rule := range expectedRules { + require.Containsf(t, actualUIDs, rule.UID, "Rule %s was expected to be deleted but it wasn't", rule.UID) + } + + require.Len(t, scheduler.Calls, len(expectedRules)) + for _, call := range scheduler.Calls { + require.Equal(t, "DeleteAlertRule", call.Method) + key, ok := call.Arguments.Get(0).(models.AlertRuleKey) + require.Truef(t, ok, "Expected AlertRuleKey but got something else") + found := false + for _, rule := range expectedRules { + if rule.GetKey() == key { + found = true + break + } + } + require.Truef(t, found, "Key %v was not expected to be submitted to scheduler", key) + } + } + + t.Run("when fine-grained access is disabled", func(t *testing.T) { + t.Run("viewer should not be authorized", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + orgID := rand.Int63() + folder := randFolder() + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) + + scheduler := &schedule.FakeScheduleService{} + scheduler.On("DeleteAlertRule", mock.Anything).Panic("should not be called") + + ac := acMock.New().WithDisabled() + request := createRequestContext(orgID, models2.ROLE_VIEWER, map[string]string{ + ":Namespace": folder.Title, + }) + response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request) + require.Equalf(t, 401, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body())) + + scheduler.AssertNotCalled(t, "DeleteAlertRule") + require.Empty(t, getRecordedCommand(ruleStore)) + }) + t.Run("editor should be able to delete all rules in folder", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + orgID := rand.Int63() + folder := randFolder() + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + rulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) + ruleStore.PutRule(context.Background(), rulesInFolder...) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) + + scheduler := &schedule.FakeScheduleService{} + scheduler.On("DeleteAlertRule", mock.Anything) + + ac := acMock.New().WithDisabled() + request := createRequestContext(orgID, models2.ROLE_EDITOR, map[string]string{ + ":Namespace": folder.Title, + }) + response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request) + require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) + assertRulesDeleted(t, rulesInFolder, ruleStore, scheduler) + }) + t.Run("editor should be able to delete rules in a group in a folder", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + orgID := rand.Int63() + groupName := util.GenerateShortUID() + folder := randFolder() + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + rulesInFolderInGroup := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName))) + ruleStore.PutRule(context.Background(), rulesInFolderInGroup...) + // rules in different groups but in the same namespace + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) + // rules in the same group but different folder + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withGroup(groupName)))...) + + scheduler := &schedule.FakeScheduleService{} + scheduler.On("DeleteAlertRule", mock.Anything) + + ac := acMock.New().WithDisabled() + request := createRequestContext(orgID, models2.ROLE_EDITOR, map[string]string{ + ":Namespace": folder.Title, + ":Groupname": groupName, + }) + response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request) + require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) + assertRulesDeleted(t, rulesInFolderInGroup, ruleStore, scheduler) + }) + }) + t.Run("when fine-grained access is enabled", func(t *testing.T) { + t.Run("and user does not have access to any of data sources used by alert rules", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + orgID := rand.Int63() + folder := randFolder() + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) + + scheduler := &schedule.FakeScheduleService{} + scheduler.On("DeleteAlertRule", mock.Anything).Panic("should not be called") + + ac := acMock.New() + request := createRequestContext(orgID, "None", map[string]string{ + ":Namespace": folder.Title, + }) + response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request) + require.Equalf(t, 401, response.Status(), "Expected 403 but got %d: %v", response.Status(), string(response.Body())) + + scheduler.AssertNotCalled(t, "DeleteAlertRule") + require.Empty(t, getRecordedCommand(ruleStore)) + }) + t.Run("and user has access to all alert rules", func(t *testing.T) { + t.Run("should delete all rules", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + orgID := rand.Int63() + folder := randFolder() + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + rulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) + ruleStore.PutRule(context.Background(), rulesInFolder...) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) + + scheduler := &schedule.FakeScheduleService{} + scheduler.On("DeleteAlertRule", mock.Anything) + + var permissions []*accesscontrol.Permission + for _, rule := range rulesInFolder { + for _, query := range rule.Data { + permissions = append(permissions, &accesscontrol.Permission{ + Action: datasources.ActionQuery, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(query.DatasourceUID), + }) + } + } + ac := acMock.New().WithPermissions(permissions) + request := createRequestContext(orgID, "None", map[string]string{ + ":Namespace": folder.Title, + }) + + response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request) + require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) + assertRulesDeleted(t, rulesInFolder, ruleStore, scheduler) + }) + }) + t.Run("and user has access to data sources of some of alert rules", func(t *testing.T) { + t.Run("should delete only those that are accessible in folder", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + orgID := rand.Int63() + folder := randFolder() + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + authorizedRulesInFolder := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder))) + ruleStore.PutRule(context.Background(), authorizedRulesInFolder...) + // more rules in the same namespace but user does not have access to them + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID)))...) + + scheduler := &schedule.FakeScheduleService{} + scheduler.On("DeleteAlertRule", mock.Anything) + + var permissions []*accesscontrol.Permission + for _, rule := range authorizedRulesInFolder { + for _, query := range rule.Data { + permissions = append(permissions, &accesscontrol.Permission{ + Action: datasources.ActionQuery, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(query.DatasourceUID), + }) + } + } + ac := acMock.New().WithPermissions(permissions) + request := createRequestContext(orgID, "None", map[string]string{ + ":Namespace": folder.Title, + }) + + response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request) + require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) + assertRulesDeleted(t, authorizedRulesInFolder, ruleStore, scheduler) + }) + t.Run("should delete only rules in a group that are authorized", func(t *testing.T) { + ruleStore := store.NewFakeRuleStore(t) + orgID := rand.Int63() + groupName := util.GenerateShortUID() + folder := randFolder() + ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder) + authorizedRulesInGroup := models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName))) + ruleStore.PutRule(context.Background(), authorizedRulesInGroup...) + // more rules in the same group but user is not authorized to access them + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder), withGroup(groupName)))...) + // rules in different groups but in the same namespace + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withNamespace(folder)))...) + // rules in the same group but different folder + ruleStore.PutRule(context.Background(), models.GenerateAlertRules(rand.Intn(4)+2, models.AlertRuleGen(withOrgID(orgID), withGroup(groupName)))...) + + scheduler := &schedule.FakeScheduleService{} + scheduler.On("DeleteAlertRule", mock.Anything) + + var permissions []*accesscontrol.Permission + for _, rule := range authorizedRulesInGroup { + for _, query := range rule.Data { + permissions = append(permissions, &accesscontrol.Permission{ + Action: datasources.ActionQuery, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(query.DatasourceUID), + }) + } + } + ac := acMock.New().WithPermissions(permissions) + request := createRequestContext(orgID, "None", map[string]string{ + ":Namespace": folder.Title, + ":Groupname": groupName, + }) + response := createService(ac, ruleStore, scheduler).RouteDeleteAlertRules(request) + require.Equalf(t, 202, response.Status(), "Expected 202 but got %d: %v", response.Status(), string(response.Body())) + assertRulesDeleted(t, authorizedRulesInGroup, ruleStore, scheduler) + }) + }) + }) +} + +func createRequestContext(orgID int64, role models2.RoleType, params map[string]string) *models2.ReqContext { + ctx := web.Context{Req: &http.Request{}} + ctx.Req = web.SetURLParams(ctx.Req, params) + + return &models2.ReqContext{ + SignedInUser: &models2.SignedInUser{ + OrgRole: role, + OrgId: orgID, + }, + Context: &ctx, + } +} + func withOrgID(orgId int64) func(rule *models.AlertRule) { return func(rule *models.AlertRule) { rule.OrgID = orgId diff --git a/pkg/services/ngalert/api/fork_ruler.go b/pkg/services/ngalert/api/fork_ruler.go index c891d7e71e5..69411ba9ce8 100644 --- a/pkg/services/ngalert/api/fork_ruler.go +++ b/pkg/services/ngalert/api/fork_ruler.go @@ -110,11 +110,11 @@ func (f *ForkedRulerApi) forkRoutePostNameRulesConfig(ctx *models.ReqContext, co } func (f *ForkedRulerApi) forkRouteDeleteNamespaceGrafanaRulesConfig(ctx *models.ReqContext) response.Response { - return f.GrafanaRuler.RouteDeleteNamespaceRulesConfig(ctx) + return f.GrafanaRuler.RouteDeleteAlertRules(ctx) } func (f *ForkedRulerApi) forkRouteDeleteGrafanaRuleGroupConfig(ctx *models.ReqContext) response.Response { - return f.GrafanaRuler.RouteDeleteRuleGroupConfig(ctx) + return f.GrafanaRuler.RouteDeleteAlertRules(ctx) } func (f *ForkedRulerApi) forkRouteGetNamespaceGrafanaRulesConfig(ctx *models.ReqContext) response.Response { diff --git a/pkg/services/ngalert/schedule/schedule.go b/pkg/services/ngalert/schedule/schedule.go index 7d607778021..962daff8f90 100644 --- a/pkg/services/ngalert/schedule/schedule.go +++ b/pkg/services/ngalert/schedule/schedule.go @@ -26,6 +26,7 @@ import ( // ScheduleService is an interface for a service that schedules the evaluation // of alert rules. +//go:generate mockery --name ScheduleService --structname FakeScheduleService --inpackage --filename schedule_mock.go type ScheduleService interface { // Run the scheduler until the context is canceled or the scheduler returns // an error. The scheduler is terminated when this function returns. diff --git a/pkg/services/ngalert/schedule/schedule_mock.go b/pkg/services/ngalert/schedule/schedule_mock.go new file mode 100644 index 00000000000..d30c1db73d4 --- /dev/null +++ b/pkg/services/ngalert/schedule/schedule_mock.go @@ -0,0 +1,118 @@ +// Code generated by mockery v2.10.0. DO NOT EDIT. + +package schedule + +import ( + context "context" + + models "github.com/grafana/grafana/pkg/services/ngalert/models" + mock "github.com/stretchr/testify/mock" + + time "time" + + url "net/url" +) + +// FakeScheduleService is an autogenerated mock type for the ScheduleService type +type FakeScheduleService struct { + mock.Mock +} + +// AlertmanagersFor provides a mock function with given fields: orgID +func (_m *FakeScheduleService) AlertmanagersFor(orgID int64) []*url.URL { + ret := _m.Called(orgID) + + var r0 []*url.URL + if rf, ok := ret.Get(0).(func(int64) []*url.URL); ok { + r0 = rf(orgID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*url.URL) + } + } + + return r0 +} + +// DeleteAlertRule provides a mock function with given fields: key +func (_m *FakeScheduleService) DeleteAlertRule(key models.AlertRuleKey) { + _m.Called(key) +} + +// DroppedAlertmanagersFor provides a mock function with given fields: orgID +func (_m *FakeScheduleService) DroppedAlertmanagersFor(orgID int64) []*url.URL { + ret := _m.Called(orgID) + + var r0 []*url.URL + if rf, ok := ret.Get(0).(func(int64) []*url.URL); ok { + r0 = rf(orgID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*url.URL) + } + } + + return r0 +} + +// Pause provides a mock function with given fields: +func (_m *FakeScheduleService) Pause() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Run provides a mock function with given fields: _a0 +func (_m *FakeScheduleService) Run(_a0 context.Context) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Unpause provides a mock function with given fields: +func (_m *FakeScheduleService) Unpause() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateAlertRule provides a mock function with given fields: key +func (_m *FakeScheduleService) UpdateAlertRule(key models.AlertRuleKey) { + _m.Called(key) +} + +// evalApplied provides a mock function with given fields: _a0, _a1 +func (_m *FakeScheduleService) evalApplied(_a0 models.AlertRuleKey, _a1 time.Time) { + _m.Called(_a0, _a1) +} + +// overrideCfg provides a mock function with given fields: cfg +func (_m *FakeScheduleService) overrideCfg(cfg SchedulerCfg) { + _m.Called(cfg) +} + +// stopApplied provides a mock function with given fields: _a0 +func (_m *FakeScheduleService) stopApplied(_a0 models.AlertRuleKey) { + _m.Called(_a0) +} diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 59308e64bc1..e76d33274b8 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -36,8 +36,6 @@ type UpsertRule struct { // Store is the interface for persisting alert rules and instances type RuleStore interface { DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error - DeleteNamespaceAlertRules(ctx context.Context, orgID int64, namespaceUID string) ([]string, error) - DeleteRuleGroupAlertRules(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) ([]string, error) DeleteAlertInstancesByRuleUID(ctx context.Context, orgID int64, ruleUID string) error GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) error GetAlertRulesForScheduling(ctx context.Context, query *ngmodels.ListAlertRulesQuery) error @@ -88,76 +86,6 @@ func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUI }) } -// DeleteNamespaceAlertRules is a handler for deleting namespace alert rules. A list of deleted rule UIDs are returned. -func (st DBstore) DeleteNamespaceAlertRules(ctx context.Context, orgID int64, namespaceUID string) ([]string, error) { - ruleUIDs := []string{} - - err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { - if err := sess.SQL("SELECT uid FROM alert_rule WHERE org_id = ? and namespace_uid = ?", orgID, namespaceUID).Find(&ruleUIDs); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_rule WHERE org_id = ? and namespace_uid = ?", orgID, namespaceUID); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_rule WHERE org_id = ? and namespace_uid = ?", orgID, namespaceUID); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_rule_version WHERE rule_org_id = ? and rule_namespace_uid = ?", orgID, namespaceUID); err != nil { - return err - } - - if _, err := sess.Exec(`DELETE FROM alert_instance WHERE rule_org_id = ? AND rule_uid NOT IN ( - SELECT uid FROM alert_rule where org_id = ? - )`, orgID, orgID); err != nil { - return err - } - - return nil - }) - return ruleUIDs, err -} - -// DeleteRuleGroupAlertRules is a handler for deleting rule group alert rules. A list of deleted rule UIDs are returned. -func (st DBstore) DeleteRuleGroupAlertRules(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) ([]string, error) { - ruleUIDs := []string{} - - err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { - if err := sess.SQL("SELECT uid FROM alert_rule WHERE org_id = ? and namespace_uid = ? and rule_group = ?", - orgID, namespaceUID, ruleGroup).Find(&ruleUIDs); err != nil { - return err - } - exist, err := sess.Exist(&ngmodels.AlertRule{OrgID: orgID, NamespaceUID: namespaceUID, RuleGroup: ruleGroup}) - if err != nil { - return err - } - - if !exist { - return ngmodels.ErrRuleGroupNamespaceNotFound - } - - if _, err := sess.Exec("DELETE FROM alert_rule WHERE org_id = ? and namespace_uid = ? and rule_group = ?", orgID, namespaceUID, ruleGroup); err != nil { - return err - } - - if _, err := sess.Exec("DELETE FROM alert_rule_version WHERE rule_org_id = ? and rule_namespace_uid = ? and rule_group = ?", orgID, namespaceUID, ruleGroup); err != nil { - return err - } - - if _, err := sess.Exec(`DELETE FROM alert_instance WHERE rule_org_id = ? AND rule_uid NOT IN ( - SELECT uid FROM alert_rule where org_id = ? - )`, orgID, orgID); err != nil { - return err - } - - return nil - }) - - return ruleUIDs, err -} - // DeleteAlertInstanceByRuleUID is a handler for deleting alert instances by alert rule UID when a rule has been updated func (st DBstore) DeleteAlertInstancesByRuleUID(ctx context.Context, orgID int64, ruleUID string) error { return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { diff --git a/pkg/services/ngalert/store/testing.go b/pkg/services/ngalert/store/testing.go index 80abb82c442..cf2ec39f5ed 100644 --- a/pkg/services/ngalert/store/testing.go +++ b/pkg/services/ngalert/store/testing.go @@ -3,13 +3,16 @@ package store import ( "context" "encoding/json" + "fmt" "io/ioutil" + "math/rand" "net/http" "net/http/httptest" "sync" "testing" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/util" models2 "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -27,6 +30,7 @@ func NewFakeRuleStore(t *testing.T) *FakeRuleStore { Hook: func(interface{}) error { return nil }, + Folders: map[int64][]*models2.Folder{}, } } @@ -38,6 +42,12 @@ type FakeRuleStore struct { Rules map[int64][]*models.AlertRule Hook func(cmd interface{}) error // use Hook if you need to intercept some query and return an error RecordedOps []interface{} + Folders map[int64][]*models2.Folder +} + +type GenericRecordedQuery struct { + Name string + Params []interface{} } // PutRule puts the rule in the Rules map. If there are existing rule in the same namespace, they will be overwritten @@ -55,6 +65,23 @@ mainloop: } rgs = append(rgs, r) f.Rules[r.OrgID] = rgs + + var existing *models2.Folder + folders := f.Folders[r.OrgID] + for _, folder := range folders { + if folder.Uid == r.NamespaceUID { + existing = folder + break + } + } + if existing == nil { + folders = append(folders, &models2.Folder{ + Id: rand.Int63(), + Uid: r.NamespaceUID, + Title: "TEST-FOLDER-" + util.GenerateShortUID(), + }) + f.Folders[r.OrgID] = folders + } } } @@ -74,15 +101,33 @@ func (f *FakeRuleStore) GetRecordedCommands(predicate func(cmd interface{}) (int return result } -func (f *FakeRuleStore) DeleteAlertRulesByUID(_ context.Context, _ int64, _ ...string) error { +func (f *FakeRuleStore) DeleteAlertRulesByUID(_ context.Context, orgID int64, UIDs ...string) error { + f.RecordedOps = append(f.RecordedOps, GenericRecordedQuery{ + Name: "DeleteAlertRulesByUID", + Params: []interface{}{orgID, UIDs}, + }) + + rules := f.Rules[orgID] + + var result = make([]*models.AlertRule, 0, len(rules)) + + for _, rule := range rules { + add := true + for _, UID := range UIDs { + if rule.UID == UID { + add = false + break + } + } + if add { + result = append(result, rule) + } + } + + f.Rules[orgID] = result return nil } -func (f *FakeRuleStore) DeleteNamespaceAlertRules(_ context.Context, _ int64, _ string) ([]string, error) { - return []string{}, nil -} -func (f *FakeRuleStore) DeleteRuleGroupAlertRules(_ context.Context, _ int64, _ string, _ string) ([]string, error) { - return []string{}, nil -} + func (f *FakeRuleStore) DeleteAlertInstancesByRuleUID(_ context.Context, _ int64, _ string) error { return nil } @@ -179,8 +224,14 @@ func (f *FakeRuleStore) GetNamespaces(_ context.Context, orgID int64, _ *models2 } return namespacesMap, nil } -func (f *FakeRuleStore) GetNamespaceByTitle(_ context.Context, _ string, _ int64, _ *models2.SignedInUser, _ bool) (*models2.Folder, error) { - return nil, nil +func (f *FakeRuleStore) GetNamespaceByTitle(_ context.Context, title string, orgID int64, _ *models2.SignedInUser, _ bool) (*models2.Folder, error) { + folders := f.Folders[orgID] + for _, folder := range folders { + if folder.Title == title { + return folder, nil + } + } + return nil, fmt.Errorf("not found") } func (f *FakeRuleStore) GetOrgRuleGroups(_ context.Context, q *models.ListOrgRuleGroupsQuery) error { f.mtx.Lock() diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index eb1900e6c81..1505ade9311 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -1890,7 +1890,7 @@ func TestAlertRuleCRUD(t *testing.T) { client := &http.Client{} // Finally, make sure we can delete it. { - t.Run("fail if he rule group name does not exists", func(t *testing.T) { + t.Run("succeed if the rule group name does not exists", func(t *testing.T) { u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default/groupnotexist", grafanaListedAddr) req, err := http.NewRequest(http.MethodDelete, u, nil) require.NoError(t, err) @@ -1903,8 +1903,8 @@ func TestAlertRuleCRUD(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - require.Equal(t, http.StatusNotFound, resp.StatusCode) - require.JSONEq(t, `{"message": "failed to delete rule group: rule group not found under this namespace"}`, string(b)) + require.Equal(t, http.StatusAccepted, resp.StatusCode) + require.JSONEq(t, `{"message":"rules deleted"}`, string(b)) }) t.Run("succeed if the rule group name does exist", func(t *testing.T) { @@ -1921,7 +1921,7 @@ func TestAlertRuleCRUD(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusAccepted, resp.StatusCode) - require.JSONEq(t, `{"message":"rule group deleted"}`, string(b)) + require.JSONEq(t, `{"message":"rules deleted"}`, string(b)) }) } }