diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index f888a2f8467..7d31ac44090 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -939,6 +939,15 @@ type ListAlertRulesQuery struct { HasPrometheusRuleDefinition *bool } +type ListAlertRulesExtendedQuery struct { + ListAlertRulesQuery + + RuleType RuleTypeFilter + + Limit int64 + ContinueToken string +} + // CountAlertRulesQuery is the query for counting alert rules type CountAlertRulesQuery struct { OrgID int64 diff --git a/pkg/services/ngalert/provisioning/accesscontrol.go b/pkg/services/ngalert/provisioning/accesscontrol.go index f7daec33459..3b451b8a9b7 100644 --- a/pkg/services/ngalert/provisioning/accesscontrol.go +++ b/pkg/services/ngalert/provisioning/accesscontrol.go @@ -14,6 +14,7 @@ type RuleAccessControlService interface { AuthorizeAccessToRuleGroup(ctx context.Context, user identity.Requester, rules models.RulesGroup) error AuthorizeAccessInFolder(ctx context.Context, user identity.Requester, namespaced models.Namespaced) error AuthorizeRuleChanges(ctx context.Context, user identity.Requester, change *store.GroupDelta) error + HasAccessInFolder(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error) } func newRuleAccessControlService(ac RuleAccessControlService) *provisioningRuleAccessControl { diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 016fbc147f6..b4efe1bc572 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -28,6 +28,7 @@ type ruleAccessControlService interface { CanReadAllRules(ctx context.Context, user identity.Requester) (bool, error) // CanWriteAllRules returns true if the user has full access to write rules via provisioning API and bypass regular checks CanWriteAllRules(ctx context.Context, user identity.Requester) (bool, error) + HasAccessInFolder(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error) } var errProvenanceMismatch = errutil.NewBase(errutil.StatusConflict, "alerting.provenanceMismatch").MustTemplate( @@ -80,6 +81,66 @@ func NewAlertRuleService(ruleStore RuleStore, } } +type ListAlertRulesOptions struct { + RuleType models.RuleTypeFilter + Limit int64 + ContinueToken string + // TODO: plumb more options +} + +func (service *AlertRuleService) ListAlertRules(ctx context.Context, user identity.Requester, opts ListAlertRulesOptions) (rules []*models.AlertRule, provenances map[string]models.Provenance, nextToken string, err error) { + q := models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: user.GetOrgID(), + }, + RuleType: opts.RuleType, + Limit: opts.Limit, + ContinueToken: opts.ContinueToken, + } + + can, err := service.authz.CanReadAllRules(ctx, user) + if err != nil { + return nil, nil, "", err + } + // If user does not have blanket privilege to read rules, filter to only folders they have rule access to + if !can { + fq := folder.GetFoldersQuery{ + OrgID: user.GetOrgID(), + SignedInUser: user, + } + folders, err := service.folderService.GetFolders(ctx, fq) + if err != nil { + return nil, nil, "", err + } + folderUIDs := make([]string, 0, len(folders)) + for _, f := range folders { + access, err := service.authz.HasAccessInFolder(ctx, user, models.Namespace(*f.ToFolderReference())) + if err != nil { + return nil, nil, "", err + } + if access { + folderUIDs = append(folderUIDs, f.UID) + } + } + q.NamespaceUIDs = folderUIDs + } + + rules, nextToken, err = service.ruleStore.ListAlertRulesPaginated(ctx, &q) + if err != nil { + return nil, nil, "", err + } + provenances = make(map[string]models.Provenance) + if len(rules) > 0 { + resourceType := rules[0].ResourceType() + provenances, err = service.provenanceStore.GetProvenances(ctx, user.GetOrgID(), resourceType) + if err != nil { + return nil, nil, "", err + } + } + + return rules, provenances, nextToken, nil +} + func (service *AlertRuleService) GetAlertRules(ctx context.Context, user identity.Requester) ([]*models.AlertRule, map[string]models.Provenance, error) { q := models.ListAlertRulesQuery{ OrgID: user.GetOrgID(), diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index ea488b83971..359a09e2e33 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -1423,6 +1423,99 @@ func TestGetRuleGroup(t *testing.T) { }) } +func TestListAlertRules(t *testing.T) { + orgID := rand.Int63() + u := &user.SignedInUser{OrgID: orgID} + groupKey1 := models.GenerateGroupKey(orgID) + groupKey2 := models.GenerateGroupKey(orgID) + gen := models.RuleGen + rules1 := gen.With(gen.WithGroupKey(groupKey1), gen.WithUniqueGroupIndex()).GenerateManyRef(3) + models.RulesGroup(rules1).SortByGroupIndex() + rules2 := gen.With(gen.WithGroupKey(groupKey2), gen.WithUniqueGroupIndex()).GenerateManyRef(4) + models.RulesGroup(rules2).SortByGroupIndex() + allRules := append(rules1, rules2...) + + fs := foldertest.NewFakeService() + fs.AddFolder(&folder.Folder{ + OrgID: orgID, + UID: groupKey1.NamespaceUID, + Title: "folder1", + }) + fs.AddFolder(&folder.Folder{ + OrgID: orgID, + UID: groupKey2.NamespaceUID, + Title: "folder2", + }) + + initServiceWithData := func(t *testing.T) (*AlertRuleService, *fakes.RuleStore, *fakes.FakeProvisioningStore, *fakeRuleAccessControlService) { + service, ruleStore, provenanceStore, ac := initService(t) + service.folderService = fs + ruleStore.Rules = map[int64][]*models.AlertRule{ + orgID: allRules, + } + ac.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error) { + return true, nil + } + + return service, ruleStore, provenanceStore, ac + } + + t.Run("when user can read all rules", func(t *testing.T) { + t.Run("should skip AuthorizeRuleGroupRead and return all rules", func(t *testing.T) { + service, _, _, ac := initServiceWithData(t) + ac.CanReadAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { + return true, nil + } + + rules, _, token, err := service.ListAlertRules(context.Background(), u, ListAlertRulesOptions{}) + require.NoError(t, err) + // check that rules contain all uids from allRules + ruleUIDs := make(map[string]bool) + for _, r := range rules { + ruleUIDs[r.UID] = true + } + for _, r := range allRules { + assert.True(t, ruleUIDs[r.UID]) + } + require.Len(t, ruleUIDs, len(allRules)) + require.Empty(t, token) + + assert.Len(t, ac.Calls, 1) + assert.Equal(t, "CanReadAllRules", ac.Calls[0].Method) + }) + }) + + t.Run("when user cannot read all rules", func(t *testing.T) { + t.Run("should return only rules in accessible folders", func(t *testing.T) { + service, _, _, ac := initServiceWithData(t) + ac.CanReadAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { + return false, nil + } + ac.HasAccessInFolderFunc = func(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error) { + return folder.GetNamespaceUID() == groupKey2.NamespaceUID, nil + } + + rules, _, token, err := service.ListAlertRules(context.Background(), u, ListAlertRulesOptions{}) + require.NoError(t, err) + // check that rules contain all uids from rules1 + ruleUIDs := make(map[string]bool) + for _, r := range rules { + ruleUIDs[r.UID] = true + } + for _, r := range rules2 { + assert.True(t, ruleUIDs[r.UID]) + } + require.Len(t, ruleUIDs, len(rules2)) + require.Empty(t, token) + + assert.Len(t, ac.Calls, 3) + assert.Equal(t, "CanReadAllRules", ac.Calls[0].Method) + assert.Equal(t, "HasAccessInFolder", ac.Calls[1].Method) + assert.Equal(t, "HasAccessInFolder", ac.Calls[2].Method) + }) + }) +} + func TestGetAlertRules(t *testing.T) { orgID := rand.Int63() u := &user.SignedInUser{OrgID: orgID} diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 6b3c2fb00d6..753c874cce3 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -32,6 +32,7 @@ type TransactionManager interface { type RuleStore interface { GetAlertRuleByUID(ctx context.Context, query *models.GetAlertRuleByUIDQuery) (*models.AlertRule, error) ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error) + ListAlertRulesPaginated(ctx context.Context, query *models.ListAlertRulesExtendedQuery) (models.RulesGroup, string, error) GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error) InsertAlertRules(ctx context.Context, user *models.UserUID, rule []models.AlertRule) ([]models.AlertRuleKeyWithId, error) UpdateAlertRules(ctx context.Context, user *models.UserUID, rule []models.UpdateRule) error diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go index beefd0287fc..059ef542d39 100644 --- a/pkg/services/ngalert/provisioning/testing.go +++ b/pkg/services/ngalert/provisioning/testing.go @@ -113,6 +113,7 @@ type fakeRuleAccessControlService struct { AuthorizeRuleChangesFunc func(ctx context.Context, user identity.Requester, change *store.GroupDelta) error CanReadAllRulesFunc func(ctx context.Context, user identity.Requester) (bool, error) CanWriteAllRulesFunc func(ctx context.Context, user identity.Requester) (bool, error) + HasAccessInFolderFunc func(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error) } func (s *fakeRuleAccessControlService) RecordCall(method string, args ...interface{}) { @@ -167,6 +168,14 @@ func (s *fakeRuleAccessControlService) CanWriteAllRules(ctx context.Context, use return false, nil } +func (s *fakeRuleAccessControlService) HasAccessInFolder(ctx context.Context, user identity.Requester, folder models.Namespaced) (bool, error) { + s.RecordCall("HasAccessInFolder", ctx, user, folder) + if s.HasAccessInFolderFunc != nil { + return s.HasAccessInFolderFunc(ctx, user, folder) + } + return true, nil +} + type fakeAlertRuleNotificationStore struct { Calls []call diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index ab8676c754d..6a19a31ff05 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -2,14 +2,16 @@ package store import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" + "math" + "slices" "strings" "github.com/google/uuid" "golang.org/x/exp/maps" - "golang.org/x/exp/slices" "github.com/grafana/grafana/pkg/util/xorm" @@ -762,8 +764,23 @@ func shouldIncludeRule(rule *ngmodels.AlertRule, query *ngmodels.ListAlertRulesB return true } -// ListAlertRules is a handler for retrieving alert rules of specific organisation. func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (result ngmodels.RulesGroup, err error) { + result, nextToken, err := st.ListAlertRulesPaginated(ctx, &ngmodels.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: *query, + ContinueToken: "", + Limit: 0, + RuleType: ngmodels.RuleTypeFilterAll, + }) + // This should never happen, as Limit is 0, which means no pagination. + if nextToken != "" { + err = fmt.Errorf("unexpected next token %q, expected empty string", nextToken) + st.Logger.Error("ListAlertRules returned a next token, but it should not have, this is a bug!", "next_token", nextToken, "query", query) + } + return result, err +} + +// ListAlertRulesPaginated is a handler for retrieving alert rules of specific organization paginated. +func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.ListAlertRulesExtendedQuery) (result ngmodels.RulesGroup, nextToken string, err error) { err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { q := sess.Table("alert_rule") @@ -819,8 +836,37 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR } } + // FIXME: record is nullable but we don't save it as null when it's nil + switch query.RuleType { + case ngmodels.RuleTypeFilterAlerting: + q = q.Where("record = ''") + case ngmodels.RuleTypeFilterRecording: + q = q.Where("record != ''") + case ngmodels.RuleTypeFilterAll: + // no additional filter + default: + return fmt.Errorf("unknown rule type filter %q", query.RuleType) + } + q = q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id") + if query.ContinueToken != "" { + cursor, err := decodeCursor(query.ContinueToken) + if err != nil { + return fmt.Errorf("invalid continue token: %w", err) + } + + // Build cursor condition that matches the ORDER BY clause + q = buildCursorCondition(q, cursor) + } + + if query.Limit > 0 { + // Ensure we clamp to the max int available on the platform + lim := min(query.Limit, math.MaxInt) + // Fetch one extra rule to determine if there are more results + q = q.Limit(int(lim) + 1) + } + alertRules := make([]*ngmodels.AlertRule, 0) rule := new(alertRule) rows, err := q.Rows(rule) @@ -833,50 +879,107 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR // Deserialize each rule separately in case any of them contain invalid JSON. for rows.Next() { - rule := new(alertRule) - err = rows.Scan(rule) - if err != nil { - st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRules", "error", err) - continue + converted, ok := st.handleRuleRow(rows, query, groupsMap) + if ok { + alertRules = append(alertRules, converted) } - converted, err := alertRuleToModelsAlertRule(*rule, st.Logger) - if err != nil { - st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRules", "error", err) - continue + } + + genToken := query.Limit > 0 && len(alertRules) > int(query.Limit) + if genToken { + // Remove the extra item we fetched + alertRules = alertRules[:query.Limit] + + // Generate next continue token from the last item + lastRule := alertRules[len(alertRules)-1] + cursor := continueCursor{ + NamespaceUID: lastRule.NamespaceUID, + RuleGroup: lastRule.RuleGroup, + RuleGroupIdx: int64(lastRule.RuleGroupIndex), + ID: lastRule.ID, } - if query.ReceiverName != "" { // remove false-positive hits from the result - if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool { - return settings.Receiver == query.ReceiverName - }) { - continue - } - } - if query.TimeIntervalName != "" { - if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool { - return slices.Contains(settings.MuteTimeIntervals, query.TimeIntervalName) || slices.Contains(settings.ActiveTimeIntervals, query.TimeIntervalName) - }) { - continue - } - } - if query.HasPrometheusRuleDefinition != nil { // remove false-positive hits from the result - if *query.HasPrometheusRuleDefinition != converted.HasPrometheusRuleDefinition() { - continue - } - } - // MySQL (and potentially other databases) can use case-insensitive comparison. - // This code makes sure we return groups that only exactly match the filter. - if groupsMap != nil { - if _, ok := groupsMap[converted.RuleGroup]; !ok { - continue - } - } - alertRules = append(alertRules, &converted) + + nextToken = encodeCursor(cursor) } result = alertRules return nil }) - return result, err + return result, nextToken, err +} + +func (st DBstore) handleRuleRow(rows *xorm.Rows, query *ngmodels.ListAlertRulesExtendedQuery, groupsSet map[string]struct{}) (*ngmodels.AlertRule, bool) { + rule := new(alertRule) + err := rows.Scan(rule) + if err != nil { + st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRules", "error", err) + return nil, false + } + converted, err := alertRuleToModelsAlertRule(*rule, st.Logger) + if err != nil { + st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRules", "error", err) + return nil, false + } + if query.ReceiverName != "" { // remove false-positive hits from the result + if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool { + return settings.Receiver == query.ReceiverName + }) { + return nil, false + } + } + if query.TimeIntervalName != "" { + if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool { + return slices.Contains(settings.MuteTimeIntervals, query.TimeIntervalName) || slices.Contains(settings.ActiveTimeIntervals, query.TimeIntervalName) + }) { + return nil, false + } + } + if query.HasPrometheusRuleDefinition != nil { // remove false-positive hits from the result + if *query.HasPrometheusRuleDefinition != converted.HasPrometheusRuleDefinition() { + return nil, false + } + } + // MySQL (and potentially other databases) can use case-insensitive comparison. + // This code makes sure we return groups that only exactly match the filter. + if groupsSet != nil { + if _, ok := groupsSet[converted.RuleGroup]; !ok { + return nil, false + } + } + return &converted, true +} + +type continueCursor struct { + NamespaceUID string `json:"n"` + RuleGroup string `json:"g"` + RuleGroupIdx int64 `json:"i"` + ID int64 `json:"d"` +} + +func encodeCursor(c continueCursor) string { + data, _ := json.Marshal(c) + return base64.URLEncoding.EncodeToString(data) +} + +func decodeCursor(token string) (continueCursor, error) { + var c continueCursor + data, err := base64.URLEncoding.DecodeString(token) + if err != nil { + return c, fmt.Errorf("failed to decode token: %w", err) + } + + if err := json.Unmarshal(data, &c); err != nil { + return c, fmt.Errorf("failed to unmarshal cursor: %w", err) + } + + return c, nil +} + +func buildCursorCondition(sess *xorm.Session, c continueCursor) *xorm.Session { + return sess.Where("(namespace_uid > ?)", c.NamespaceUID). + Or("(namespace_uid = ? AND rule_group > ?)", c.NamespaceUID, c.RuleGroup). + Or("(namespace_uid = ? AND rule_group = ? AND rule_group_idx > ?)", c.NamespaceUID, c.RuleGroup, c.RuleGroupIdx). + Or("(namespace_uid = ? AND rule_group = ? AND rule_group_idx = ? AND id > ?)", c.NamespaceUID, c.RuleGroup, c.RuleGroupIdx, c.ID) } // Count returns either the number of the alert rules under a specific org (if orgID is not zero) diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index c3031528013..1e0119fe3fa 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -1956,6 +1956,146 @@ func TestIntegration_ListAlertRules(t *testing.T) { }) } +func TestIntegration_ListAlertRulesPaginated(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{ + BaseInterval: time.Duration(rand.Int64N(100)) * time.Second, + } + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + b := &fakeBus{} + orgID := int64(1) + ruleGen := models.RuleGen + ruleGen = ruleGen.With( + ruleGen.WithIntervalMatching(cfg.UnifiedAlerting.BaseInterval), + ruleGen.WithOrgID(orgID), + ) + t.Run("filter by RuleType", func(t *testing.T) { + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + alertingGen := ruleGen + recordingGen := ruleGen.With(models.RuleMuts.WithAllRecordingRules(), models.RuleMuts.WithMetric("metric1"), models.RuleMuts.WithRecordFrom("A")) + + alertingRules := []*models.AlertRule{ + createRule(t, store, alertingGen), + createRule(t, store, alertingGen), + } + recordingRules := []*models.AlertRule{ + createRule(t, store, recordingGen), + createRule(t, store, recordingGen), + } + + t.Run("should return only alerting rules", func(t *testing.T) { + query := &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: orgID, + }, + RuleType: models.RuleTypeFilterAlerting, + } + result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query) + require.NoError(t, err) + require.Empty(t, continueToken, "continue token should be empty when no pagination is applied") + require.NotEmpty(t, result) + for _, rule := range result { + require.Equal(t, models.RuleTypeAlerting, rule.Type()) + } + }) + + t.Run("should return only recording rules", func(t *testing.T) { + query := &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: orgID, + }, + RuleType: models.RuleTypeFilterRecording, + } + result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query) + require.NoError(t, err) + require.Empty(t, continueToken, "continue token should be empty when no pagination is applied") + require.NotEmpty(t, result) + for _, rule := range result { + require.Equal(t, models.RuleTypeRecording, rule.Type()) + } + }) + + t.Run("should return both alerting and recording rules when RuleType is not set", func(t *testing.T) { + query := &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: orgID, + }, + } + result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query) + require.NoError(t, err) + require.Empty(t, continueToken, "continue token should be empty when no pagination is applied") + require.NotEmpty(t, result) + var alertingCount, recordingCount int + for _, rule := range result { + switch rule.Type() { + case models.RuleTypeAlerting: + alertingCount++ + case models.RuleTypeRecording: + recordingCount++ + } + } + require.GreaterOrEqual(t, alertingCount, len(alertingRules)) + require.GreaterOrEqual(t, recordingCount, len(recordingRules)) + }) + t.Run("should return both alerting and recording rules when RuleType is all", func(t *testing.T) { + query := &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: orgID, + }, + RuleType: models.RuleTypeFilterAll, + } + result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query) + require.NoError(t, err) + require.Empty(t, continueToken, "continue token should be empty when no pagination is applied") + require.NotEmpty(t, result) + var alertingCount, recordingCount int + for _, rule := range result { + switch rule.Type() { + case models.RuleTypeAlerting: + alertingCount++ + case models.RuleTypeRecording: + recordingCount++ + } + } + require.GreaterOrEqual(t, alertingCount, len(alertingRules)) + require.GreaterOrEqual(t, recordingCount, len(recordingRules)) + }) + }) + t.Run("list rules with pagination", func(t *testing.T) { + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + alertingGen := ruleGen.With(ruleGen.WithNamespaceUID("paginate-test")) + for i := 0; i < 10; i++ { + createRule(t, store, alertingGen) + } + t.Run("should return paginated results", func(t *testing.T) { + query := &models.ListAlertRulesExtendedQuery{ + ListAlertRulesQuery: models.ListAlertRulesQuery{ + OrgID: orgID, + NamespaceUIDs: []string{"paginate-test"}, + }, + Limit: 5, // set page size to 5 + } + result, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query) + require.NoError(t, err) + require.Len(t, result, 5, "should return 5 rules as per page size") + require.NotEmpty(t, continueToken, "continue token should not be empty for paginated results") + + // continue with the next page + query.ContinueToken = continueToken + result2, continueToken, err := store.ListAlertRulesPaginated(context.Background(), query) + require.NoError(t, err) + require.Len(t, result2, 5, "should return next 5 rules") + require.Empty(t, continueToken, "continue token should be empty when all rules are fetched") + + require.NotElementsMatch(t, result, result2, "should not have same rules in both pages") + }) + }) +} + func TestIntegration_ListDeletedRules(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/ngalert/store/models.go b/pkg/services/ngalert/store/models.go index f7b6858a86e..775f6f54542 100644 --- a/pkg/services/ngalert/store/models.go +++ b/pkg/services/ngalert/store/models.go @@ -19,8 +19,8 @@ type alertRule struct { DashboardUID *string `xorm:"dashboard_uid"` PanelID *int64 `xorm:"panel_id"` RuleGroup string - RuleGroupIndex int `xorm:"rule_group_idx"` - Record string + RuleGroupIndex int `xorm:"rule_group_idx"` + Record string // FIXME: record is nullable but we don't save it as null when it's nil NoDataState string ExecErrState string For time.Duration diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 8e7d92666a5..955f2c8091f 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -268,6 +268,22 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert return outputRules, nextToken, nil } +// TODO: implement pagination for this fake +func (f *RuleStore) ListAlertRulesPaginated(_ context.Context, q *models.ListAlertRulesExtendedQuery) (models.RulesGroup, string, error) { + f.mtx.Lock() + defer f.mtx.Unlock() + f.RecordedOps = append(f.RecordedOps, *q) + + if err := f.Hook(*q); err != nil { + return nil, "", err + } + rules, err := f.listAlertRules(&q.ListAlertRulesQuery) + if err != nil { + return nil, "", err + } + return rules, "", nil +} + func (f *RuleStore) ListAlertRules(_ context.Context, q *models.ListAlertRulesQuery) (models.RulesGroup, error) { f.mtx.Lock() defer f.mtx.Unlock()