diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 9755a28505c..7f1e9c8e4a2 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -1069,6 +1069,154 @@ func TestRouteGetRuleStatuses(t *testing.T) { }) }) + t.Run("when requesting rules with rule_limit pagination", func(t *testing.T) { + const namespaceUID = "namespace_0" + + ruleStore := fakes.NewRuleStore(t) + fakeAIM := NewFakeAlertInstanceManager(t) + + // Generate 3 rule groups with 10 rules each + allRules := make([]*ngmodels.AlertRule, 0, 30) + for i := range 3 { + rules := gen.With(gen.WithGroupKey(ngmodels.AlertRuleGroupKey{ + RuleGroup: fmt.Sprintf("rule_group_%d", i), + NamespaceUID: namespaceUID, + OrgID: orgID, + })).GenerateManyRef(10) + + allRules = append(allRules, rules...) + ruleStore.PutRule(context.Background(), rules...) + } + + api := NewPrometheusSrv( + log.NewNopLogger(), + fakeAIM, + newFakeSchedulerReader(t).setupStates(fakeAIM), + ruleStore, + accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures())), + fakes.NewFakeProvisioningStore(), + ) + + permissions := createPermissionsForRules(allRules, orgID) + user := &user.SignedInUser{ + OrgID: orgID, + Permissions: permissions, + } + c := &contextmodel.ReqContext{ + SignedInUser: user, + } + + t.Run("should return complete groups until rule_limit is met", func(t *testing.T) { + // With rule_limit=15, should return group-0 (10) + group-1 (10) + // Even though 20 > 15, we never return partial groups + r, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=15", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "should return 2 groups") + require.Len(t, result.Data.Totals, 0) + require.Equal(t, "rule_group_0", result.Data.RuleGroups[0].Name) + require.Equal(t, "rule_group_1", result.Data.RuleGroups[1].Name) + require.Len(t, result.Data.RuleGroups[0].Rules, 10) + require.Len(t, result.Data.RuleGroups[1].Rules, 10) + + expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{ + NamespaceUID: namespaceUID, + RuleGroup: "rule_group_1", + }) + require.Equal(t, expectedToken, result.Data.NextToken) + }) + + t.Run("should return two groups when the number of alerts == limit", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=20", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "should return 2 groups") + require.Len(t, result.Data.Totals, 0) + require.Equal(t, "rule_group_0", result.Data.RuleGroups[0].Name) + require.Equal(t, "rule_group_1", result.Data.RuleGroups[1].Name) + require.Len(t, result.Data.RuleGroups[0].Rules, 10) + require.Len(t, result.Data.RuleGroups[1].Rules, 10) + + expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{ + NamespaceUID: namespaceUID, + RuleGroup: "rule_group_1", + }) + require.Equal(t, expectedToken, result.Data.NextToken) + }) + + t.Run("should respect group_limit when it is reached first", func(t *testing.T) { + // group_limit=1 with rule_limit=100: group limit reached first + r, err := http.NewRequest("GET", "/api/v1/rules?group_limit=1&rule_limit=100", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 1, "should return 1 group (group_limit=1)") + require.Len(t, result.Data.Totals, 0) + + expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{ + NamespaceUID: namespaceUID, + RuleGroup: "rule_group_0", + }) + require.Equal(t, expectedToken, result.Data.NextToken) + }) + + t.Run("should respect rule_limit when it is reached first", func(t *testing.T) { + // rule_limit=15 with group_limit=5: rule limit reached first (returns 2 groups, 20 rules) + r, err := http.NewRequest("GET", "/api/v1/rules?group_limit=5&rule_limit=15", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "should return 2 groups (20 rules exceeds rule_limit=15)") + require.Len(t, result.Data.Totals, 0) + + expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{ + NamespaceUID: namespaceUID, + RuleGroup: "rule_group_1", + }) + require.Equal(t, expectedToken, result.Data.NextToken) + }) + + t.Run("should return nothing when using rule_limit=0", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=0", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 0) + }) + }) + t.Run("when fine-grained access is enabled", func(t *testing.T) { t.Run("should return only rules if the user can query all data sources", func(t *testing.T) { ruleStore := fakes.NewRuleStore(t) diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index e7a6b7f57a4..99cb0d95530 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -492,10 +492,22 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleType = ngmodels.RuleTypeFilterAll } + // Pagination limits + // + // group_limit: Maximum number of rule groups to return + // - Returns exactly this many groups or fewer if not enough exist + // + // rule_limit: Maximum number of rules to return across all groups + // - Returns complete groups until total rules meets or exceeds this limit + // - May exceed the rule limit if needed to include the final complete group + // - Example: rule_limit=15 with groups of [10, 10, 10] rules returns first 2 groups, 20 rules total + // + // When both limits are specified, whichever limit is reached first takes precedence. maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1) + maxRules := getInt64WithDefault(opts.Query, "rule_limit", -1) nextToken := opts.Query.Get("group_next_token") - if maxGroups == 0 { + if maxGroups == 0 || maxRules == 0 { return ruleResponse } @@ -512,6 +524,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt }, RuleType: ruleType, Limit: maxGroups, + RuleLimit: maxRules, ContinueToken: nextToken, } ruleList, continueToken, err := store.ListAlertRulesByGroup(opts.Ctx, &byGroupQuery) @@ -557,7 +570,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleResponse.Data.NextToken = continueToken // Only return Totals if there is no pagination - if maxGroups == -1 { + if maxGroups == -1 && maxRules == -1 { ruleResponse.Data.Totals = rulesTotals } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 4823cd977fe..9f899bc2af7 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -994,6 +994,7 @@ type ListAlertRulesExtendedQuery struct { RuleType RuleTypeFilter Limit int64 + RuleLimit int64 ContinueToken string } diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 393bfdabc91..9044bb369c5 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -618,6 +618,7 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis // Process rules and implement per-group pagination var groupsFetched int64 + var rulesFetched int64 for rows.Next() { rule := new(alertRule) err = rows.Scan(rule) @@ -644,6 +645,11 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis nextToken = ngmodels.EncodeGroupCursor(cursor) break } + // Check if we've reached the rule limit + if query.RuleLimit > 0 && rulesFetched >= query.RuleLimit { + nextToken = ngmodels.EncodeGroupCursor(cursor) + break + } // Reset for new group cursor = key @@ -656,6 +662,7 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis } alertRules = append(alertRules, &converted) + rulesFetched++ } result = alertRules diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 287a788399e..a0dd60e32e5 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -234,12 +234,13 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert } } - if q.Limit < 0 { + if q.Limit < 0 && q.RuleLimit < 0 { return ruleList, "", nil } outputRules := make([]*models.AlertRule, 0, len(ruleList)) var groupsFetched int64 + var rulesFetched int64 initialCursor := cursor for _, r := range ruleList { // skip rules before the initial cursor @@ -258,11 +259,16 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert nextToken = models.EncodeGroupCursor(cursor) break } + if q.RuleLimit > 0 && rulesFetched >= q.RuleLimit { + nextToken = models.EncodeGroupCursor(cursor) + break + } cursor = key groupsFetched++ } outputRules = append(outputRules, r) + rulesFetched++ } return outputRules, nextToken, nil diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index c7b0a1c8c5f..23681381fc9 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -361,6 +361,161 @@ func TestIntegrationPrometheusRules(t *testing.T) { } } +func TestIntegrationPrometheusRulesPagination(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + testinfra.SQLiteIntegrationTest(t) + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + apiClient.CreateFolder(t, "default", "default") + + interval, err := model.ParseDuration("10s") + require.NoError(t, err) + + // Create 3 rule groups with different numbers of rules + // Group 1: 5 rules, Group 2: 3 rules, Group 3: 2 rules (total: 10 rules) + for groupIdx := 1; groupIdx <= 3; groupIdx++ { + var rulesCount int + switch groupIdx { + case 1: + rulesCount = 5 + case 2: + rulesCount = 3 + case 3: + rulesCount = 2 + } + + rules := make([]apimodels.PostableExtendedRuleNode, rulesCount) + for i := 0; i < rulesCount; i++ { + rules[i] = apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: fmt.Sprintf("rule-%d-%d", groupIdx, i+1), + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "0 > 1" + }`), + }, + }, + }, + } + } + + ruleGroup := apimodels.PostableRuleGroupConfig{ + Name: fmt.Sprintf("group-%d", groupIdx), + Rules: rules, + } + + apiClient.PostRulesGroup(t, "default", &ruleGroup, false) + } + + t.Run("with group_limit should return only 2 groups", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?group_limit=2", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, result.Data.RuleGroups, 2) + require.NotEmpty(t, result.Data.NextToken) + }) + + // Test rule_limit: with limit of 7, should return groups 1 and 2 (5+3=8 rules in total expected) + t.Run("with rule_limit should return full groups with rules limit", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_limit=7", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, result.Data.RuleGroups, 2) + + totalRules := 0 + for _, group := range result.Data.RuleGroups { + totalRules += len(group.Rules) + } + require.Equal(t, 8, totalRules) + require.NotEmpty(t, result.Data.NextToken) + }) + + // With both group_limit and rule_limit set, the API should return + // data with respect to whichever limit is reached first. + t.Run("both limits respect whichever is reached first", func(t *testing.T) { + // group_limit=1 with rule_limit=100: group limit reached first + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?group_limit=1&rule_limit=100", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, result.Data.RuleGroups, 1) + }) + + t.Run("rule_limit=0 returns empty results", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_limit=0", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, result.Data.RuleGroups, 0, "should return no groups") + }) +} + func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t)