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 <william.wernert@grafana.com>
This commit is contained in:
co-authored by
William Wernert
parent
a7e4447d0e
commit
5724fae778
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user