From 5724fae7784760a86d01345ec200be90a3a6c9e6 Mon Sep 17 00:00:00 2001 From: Moustafa Baiou Date: Tue, 26 Aug 2025 08:20:47 -0400 Subject: [PATCH] Alerting: Add Extended List Query for Alert Rules w/pagination (#109360) * Alerting: Add Extended List Query for Alert Rules w/pagination This adds an extended query which allows filtering by the kind of rule (Recording or Alerting) and supports pagination. Pagination tokens will allow us to list all rules paginated, regardless of the rule group. --------- Co-authored-by: William Wernert --- pkg/services/ngalert/models/alert_rule.go | 9 + .../ngalert/provisioning/accesscontrol.go | 1 + .../ngalert/provisioning/alert_rules.go | 61 ++++++ .../ngalert/provisioning/alert_rules_test.go | 93 +++++++++ pkg/services/ngalert/provisioning/persist.go | 1 + pkg/services/ngalert/provisioning/testing.go | 9 + pkg/services/ngalert/store/alert_rule.go | 181 ++++++++++++++---- pkg/services/ngalert/store/alert_rule_test.go | 140 ++++++++++++++ pkg/services/ngalert/store/models.go | 4 +- pkg/services/ngalert/tests/fakes/rules.go | 16 ++ 10 files changed, 474 insertions(+), 41 deletions(-) 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()