Alerting: Add support for alpha rules apis in legacy storage

Rules created in the new api makes the rule have no group in the database, but the rule is returned in the old group api with a sentinel group name formatted with the rule uid for compatiblity with the old api.
This makes the UI continue to work with the rules without a group, and the ruler will continue to work with the rules without a group.

Rules are not allowed to be created in the provisioning api with a NoGroup sentinel mask, but NoGroup rules can be manipulated through both the new and old apis.

Co-authored-by: William Wernert <william.wernert@grafana.com>
This commit is contained in:
Moustafa Baiou
2025-09-10 09:30:56 -04:00
committed by Moustafa Baiou
co-authored by William Wernert
parent 0a85a30642
commit ca8324e62a
50 changed files with 8729 additions and 110 deletions
+7
View File
@@ -462,6 +462,13 @@ var (
Owner: grafanaAppPlatformSquad,
RequiresRestart: true, // changes the API routing
},
{
Name: "kubernetesAlertingRules",
Description: "Adds support for Kubernetes alerting and recording rules",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
RequiresRestart: true,
},
{
Name: "dashboardDisableSchemaValidationV1",
Description: "Disable schema validation for dashboards/v1",
+1
View File
@@ -59,6 +59,7 @@ kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,
kubernetesLibraryPanels,experimental,@grafana/grafana-app-platform-squad,false,true,false
kubernetesDashboards,GA,@grafana/dashboards-squad,false,false,true
kubernetesShortURLs,experimental,@grafana/grafana-app-platform-squad,false,true,false
kubernetesAlertingRules,experimental,@grafana/alerting-squad,false,true,false
dashboardDisableSchemaValidationV1,experimental,@grafana/grafana-app-platform-squad,false,false,false
dashboardDisableSchemaValidationV2,experimental,@grafana/grafana-app-platform-squad,false,false,false
dashboardSchemaValidationLogging,experimental,@grafana/grafana-app-platform-squad,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
59 kubernetesLibraryPanels experimental @grafana/grafana-app-platform-squad false true false
60 kubernetesDashboards GA @grafana/dashboards-squad false false true
61 kubernetesShortURLs experimental @grafana/grafana-app-platform-squad false true false
62 kubernetesAlertingRules experimental @grafana/alerting-squad false true false
63 dashboardDisableSchemaValidationV1 experimental @grafana/grafana-app-platform-squad false false false
64 dashboardDisableSchemaValidationV2 experimental @grafana/grafana-app-platform-squad false false false
65 dashboardSchemaValidationLogging experimental @grafana/grafana-app-platform-squad false false false
+4
View File
@@ -247,6 +247,10 @@ const (
// Routes short url requests from /api to the /apis endpoint
FlagKubernetesShortURLs = "kubernetesShortURLs"
// FlagKubernetesAlertingRules
// Adds support for Kubernetes alerting and recording rules
FlagKubernetesAlertingRules = "kubernetesAlertingRules"
// FlagDashboardDisableSchemaValidationV1
// Disable schema validation for dashboards/v1
FlagDashboardDisableSchemaValidationV1 = "dashboardDisableSchemaValidationV1"
+13
View File
@@ -1908,6 +1908,19 @@
"requiresRestart": true
}
},
{
"metadata": {
"name": "kubernetesAlertingRules",
"resourceVersion": "1754340669702",
"creationTimestamp": "2025-08-04T20:51:09Z"
},
"spec": {
"description": "Adds support for Kubernetes alerting and recording rules",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"requiresRestart": true
}
},
{
"metadata": {
"name": "kubernetesAuthnMutation",
+3
View File
@@ -550,6 +550,9 @@ func (srv RulerSrv) performUpdateAlertRules(ctx context.Context, c *contextmodel
updates := make([]ngmodels.UpdateRule, 0, len(finalChanges.Update))
for _, update := range finalChanges.Update {
logger.Debug("Updating rule", "rule_uid", update.New.UID, "diff", update.Diff.String())
if ngmodels.IsNoGroupRuleGroup(update.Existing.RuleGroup) && !ngmodels.IsNoGroupRuleGroup(update.New.RuleGroup) {
return fmt.Errorf("%w: cannot move rule out of this group", ngmodels.ErrAlertRuleFailedValidation)
}
updates = append(updates, ngmodels.UpdateRule{
Existing: update.Existing,
New: *update.New,
@@ -329,7 +329,9 @@ func ValidateRuleGroup(
return nil, errors.New("rule group name cannot be empty")
}
if len(ruleGroupConfig.Name) > store.AlertRuleMaxRuleGroupNameLength {
isNoGroupRuleGroup := ngmodels.IsNoGroupRuleGroup(ruleGroupConfig.Name)
if len(ruleGroupConfig.Name) > store.AlertRuleMaxRuleGroupNameLength && !isNoGroupRuleGroup {
return nil, fmt.Errorf("rule group name is too long. Max length is %d", store.AlertRuleMaxRuleGroupNameLength)
}
@@ -345,6 +347,11 @@ func ValidateRuleGroup(
// TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval
// If the rule group is reserved for no-group rules, we cannot have multiple rules in it.
if isNoGroupRuleGroup && len(ruleGroupConfig.Rules) > 1 {
return nil, fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", ruleGroupConfig.Name)
}
result := make([]*ngmodels.AlertRuleWithOptionals, 0, len(ruleGroupConfig.Rules))
uids := make(map[string]int, cap(result))
for idx := range ruleGroupConfig.Rules {
+49
View File
@@ -281,6 +281,55 @@ func NewUserUID(requester interface{ GetIdentifier() string }) *UserUID {
return &userUID
}
const (
NoGroupPrefix = "no_group_for_rule_"
NoGroupNameLength = 200
)
// NoGroupRuleGroup is a special rule group that is used to represent rules that do not belong to any group.
type NoGroupRuleGroup struct {
ruleUID string
}
func NewNoGroupRuleGroup(ruleUID string) (*NoGroupRuleGroup, error) {
// Generate a "no group" string that exceeds 190 char limit to fail validation
// This is to ensure that the rule group is not created in the database.
if len(ruleUID) > NoGroupNameLength-len(NoGroupPrefix) {
return nil, fmt.Errorf("rule UID is too long: %s", ruleUID)
}
return &NoGroupRuleGroup{ruleUID: ruleUID}, nil
}
func (ruleGroup *NoGroupRuleGroup) String() string {
sb := strings.Builder{}
sb.WriteString(NoGroupPrefix)
sb.WriteString(ruleGroup.ruleUID)
for sb.Len() < NoGroupNameLength {
sb.WriteRune('*')
}
return sb.String()
}
func (ruleGroup *NoGroupRuleGroup) GetRuleUID() string {
return ruleGroup.ruleUID
}
func IsNoGroupRuleGroup(ruleGroup string) bool {
return strings.HasPrefix(ruleGroup, NoGroupPrefix) && len(ruleGroup) == NoGroupNameLength &&
strings.Count(ruleGroup, "*") >= (NoGroupNameLength-len(NoGroupPrefix)-util.MaxUIDLength)
}
func ParseNoRuleGroup(ruleGroup string) (*NoGroupRuleGroup, error) {
if !IsNoGroupRuleGroup(ruleGroup) {
return nil, fmt.Errorf("rule group %s is not a no group rule group", ruleGroup)
}
ruleUID := strings.TrimRight(strings.TrimPrefix(ruleGroup, NoGroupPrefix), "*")
if err := util.ValidateUID(ruleUID); err != nil {
return nil, fmt.Errorf("rule group %s is not a no group rule group, rule uid could not be parsed: %w", ruleGroup, err)
}
return &NoGroupRuleGroup{ruleUID: ruleUID}, nil
}
// AlertRule is the model for alert rules in unified alerting.
type AlertRule struct {
ID int64
+1 -1
View File
@@ -314,7 +314,7 @@ func (a *AlertRuleMutators) WithIntervalSeconds(seconds int64) AlertRuleMutator
}
}
// WithIntervalMatching mutator that generates random interval and `for` duration that are times of the provided base interval.
// WithIntervalMatching mutator that generates random interval and `for` duration that are multiples of the provided base interval.
func (a *AlertRuleMutators) WithIntervalMatching(baseInterval time.Duration) AlertRuleMutator {
return func(rule *AlertRule) {
rule.IntervalSeconds = int64(baseInterval.Seconds()) * (rand.Int63n(10) + 1)
@@ -242,10 +242,13 @@ func (service *AlertRuleService) GetAlertRuleWithFolderFullpath(ctx context.Cont
}, nil
}
// CreateAlertRule creates a new alert rule. This function will ignore any
// interval that is set in the rule struct and use the already existing group
// interval or the default one.
// CreateAlertRule creates a new alert rule. For normal rule groups, this function will ignore any
// interval that is set in the rule struct and use the already existing group interval or the default one.
func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user identity.Requester, rule models.AlertRule, provenance models.Provenance) (models.AlertRule, error) {
if models.IsNoGroupRuleGroup(rule.RuleGroup) {
return models.AlertRule{}, fmt.Errorf("%w: rules must have a valid group", models.ErrAlertRuleFailedValidation)
}
if rule.UID == "" {
rule.UID = util.GenerateShortUID()
} else if err := util.ValidateUID(rule.UID); err != nil {
@@ -281,7 +284,9 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user ident
interval = existingGroup[0].IntervalSeconds
}
}
rule.IntervalSeconds = interval
if rule.RuleGroup != "" {
rule.IntervalSeconds = interval
}
err = rule.SetDashboardAndPanelFromAnnotations()
if err != nil {
return models.AlertRule{}, err
@@ -464,6 +469,11 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user iden
return err
}
// If the rule group is reserved for no-group rules, we cannot have multiple rules in it.
if models.IsNoGroupRuleGroup(group.Title) && len(group.Rules) > 1 {
return fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", group.Title)
}
for _, rule := range group.Rules {
if rule.UID == "" {
// if empty the UID will be generated before save
@@ -653,6 +663,9 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
},
})
}
if models.IsNoGroupRuleGroup(update.Existing.RuleGroup) && !models.IsNoGroupRuleGroup(update.New.RuleGroup) {
return fmt.Errorf("%w: cannot move rule out of this group", models.ErrAlertRuleFailedValidation)
}
updates = append(updates, models.UpdateRule{
Existing: update.Existing,
New: *update.New,
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"slices"
"strconv"
@@ -748,6 +749,124 @@ func TestIntegrationAlertRuleService(t *testing.T) {
require.ErrorIs(t, err, models.ErrQuotaReached)
})
t.Run("alert rules created without a group should be considered NoGroup rules", func(t *testing.T) {
rule := createNoGroupRule("test-no-group-rule", orgID, "my-namespace")
// This is the way legacy storage creates rules without a group
rule.RuleGroup = ""
rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
require.NoError(t, err)
require.Equal(t, int64(60), rule.IntervalSeconds)
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(rule.RuleGroup), "Rule should be considered NoGroup rule")
ruleGroup, err := ruleService.GetRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup)
require.NoError(t, err)
require.NotNil(t, ruleGroup)
require.True(t, models.IsNoGroupRuleGroup(ruleGroup.Title), "Rule group should be NoGroup rule group")
require.Len(t, ruleGroup.Rules, 1, "Rule group should only contain one NoGroup rule")
})
t.Run("multiple alert rules created without a group should be considered NoGroup rules, and be returned in separate groups", func(t *testing.T) {
rule := createNoGroupRule("test-no-group-rule-1", orgID, "my-namespace")
// This is the way legacy storage creates rules without a group
rule.RuleGroup = ""
rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
require.NoError(t, err)
require.Equal(t, int64(60), rule.IntervalSeconds)
rule2 := createNoGroupRule("test-no-group-rule-2", orgID, "my-namespace")
// This is the way legacy storage creates rules without a group
rule2.RuleGroup = ""
rule2, err = ruleService.CreateAlertRule(context.Background(), u, rule2, models.ProvenanceNone)
require.NoError(t, err)
require.Equal(t, int64(60), rule.IntervalSeconds)
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(rule.RuleGroup), "Rule should be considered NoGroup rule")
rule2, _, err = ruleService.GetAlertRule(context.Background(), u, rule2.UID)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(rule2.RuleGroup), "Rule should be considered NoGroup rule")
require.NotEqual(t, rule.RuleGroup, rule2.RuleGroup, "Both rules should have different NoGroup rule groups")
ruleGroup, err := ruleService.GetRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup)
require.NoError(t, err)
require.NotNil(t, ruleGroup)
require.True(t, models.IsNoGroupRuleGroup(ruleGroup.Title), "Rule group should be NoGroup rule group")
require.Len(t, ruleGroup.Rules, 1, "Rule group should only contain one NoGroup rule")
ruleGroup2, err := ruleService.GetRuleGroup(context.Background(), u, rule2.NamespaceUID, rule2.RuleGroup)
require.NoError(t, err)
require.NotNil(t, ruleGroup2)
require.True(t, models.IsNoGroupRuleGroup(ruleGroup2.Title), "Rule group should be NoGroup rule group")
require.Len(t, ruleGroup2.Rules, 1, "Rule group should only contain one NoGroup rule")
require.NotEqual(t, ruleGroup, ruleGroup2, "Both NoGroup rule groups should be different")
})
t.Run("setting the group interval on NoGroup rules should only affect 1 rule", func(t *testing.T) {
rule := createNoGroupRule("test-no-group-rule-1", orgID, "my-namespace")
// This is the way legacy storage creates rules without a group
rule.RuleGroup = ""
rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
require.NoError(t, err)
require.Equal(t, int64(60), rule.IntervalSeconds)
rule2 := createNoGroupRule("test-no-group-rule-2", orgID, "my-namespace")
// This is the way legacy storage creates rules without a group
rule2.RuleGroup = ""
rule2, err = ruleService.CreateAlertRule(context.Background(), u, rule2, models.ProvenanceNone)
require.NoError(t, err)
require.Equal(t, int64(60), rule.IntervalSeconds)
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(rule.RuleGroup), "Rule should be considered NoGroup rule")
rule2, _, err = ruleService.GetAlertRule(context.Background(), u, rule2.UID)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(rule2.RuleGroup), "Rule should be considered NoGroup rule")
require.NotEqual(t, rule.RuleGroup, rule2.RuleGroup, "Both rules should have different NoGroup rule groups")
var updatedInterval int64 = 120
err = ruleService.UpdateRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup, updatedInterval)
require.NoError(t, err)
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
require.NoError(t, err)
require.Equal(t, updatedInterval, rule.IntervalSeconds, "Rule should have updated interval")
rule2, _, err = ruleService.GetAlertRule(context.Background(), u, rule2.UID)
require.NoError(t, err)
require.Equal(t, int64(60), rule2.IntervalSeconds, "Rule should not have updated interval")
require.NotEqual(t, updatedInterval, rule2.IntervalSeconds, "Both rules should not have updated interval")
})
t.Run("alert rule in NoGroup should be updated correctly", func(t *testing.T) {
rule := createNoGroupRule("test-no-group-rule", orgID, "my-namespace")
// This is the way legacy storage creates rules without a group
rule.RuleGroup = ""
rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
require.NoError(t, err)
require.Equal(t, int64(60), rule.IntervalSeconds)
// get the actual calculated group for use with the api
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(rule.RuleGroup), "Rule should be considered NoGroup rule")
err = ruleService.UpdateRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup, 120)
require.NoError(t, err)
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
require.NoError(t, err)
require.Equal(t, int64(120), rule.IntervalSeconds)
})
}
func TestIntegrationCreateAlertRule(t *testing.T) {
@@ -1028,6 +1147,25 @@ func TestIntegrationCreateAlertRule(t *testing.T) {
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
})
})
t.Run("should not allow creating with a NoGroup Rule", func(t *testing.T) {
// NoGroup rules are not allowed to be created directly via provisioning, they must be created via new k8s apis
ruleWNoGroup := createNoGroupRule("test_No_group_create_disallowed", orgID, "test-no-group-ns")
_, err := ruleService.CreateAlertRule(context.Background(), u, ruleWNoGroup, models.ProvenanceNone)
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
require.ErrorContains(t, err, "rules must have a valid group")
})
t.Run("should allow creating Rule without a group", func(t *testing.T) {
ruleWNoGroup := createNoGroupRule("test_No_group_create_allowed", orgID, "test-no-group-ns")
ruleWNoGroup.RuleGroup = "" // This is the way legacy storage creates rules without a group
_, err := ruleService.CreateAlertRule(context.Background(), u, ruleWNoGroup, models.ProvenanceNone)
require.NoError(t, err)
// We should be able to retrieve the rule and see that it is a NoGroup rule
retrievedRule, _, err := ruleService.GetAlertRule(context.Background(), u, ruleWNoGroup.UID)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(retrievedRule.RuleGroup), "Rule should be considered NoGroup rule")
})
}
func TestUpdateAlertRule(t *testing.T) {
@@ -1132,7 +1270,7 @@ func TestUpdateAlertRule(t *testing.T) {
})
t.Run("when there are no changes it should be successful", func(t *testing.T) {
// For this test we will not change the rule, and we will not use "admin" (CanWriteAllRulesFunc)
// For this test we will not change the rule, and we will not use "admin" (CanWriteAllRules)
// permissions. The response of the service should still be successful.
service, ruleStore, _, ac := initServiceWithData(t)
@@ -1155,6 +1293,29 @@ func TestUpdateAlertRule(t *testing.T) {
require.Empty(t, updates)
})
})
// NoGroup-specific tests for UpdateAlertRule
t.Run("NoGroup: UpdateAlertRule preserves interval and sentinel group", func(t *testing.T) {
service, ruleStore, provenanceStore, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil }
rule := createNoGroupRule("nogroup-update", orgID, "my-namespace")
_, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{rule})
require.NoError(t, err)
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &rule, orgID, models.ProvenanceNone))
// mutate fields and attempt to change interval via UpdateAlertRule
rule.Title = "nogroup-update-new"
originalInterval := int64(60)
require.Equal(t, originalInterval, rule.IntervalSeconds)
rule.IntervalSeconds = originalInterval + 60
updated, err := service.UpdateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(updated.RuleGroup))
require.Equal(t, "nogroup-update-new", updated.Title)
require.Equal(t, originalInterval, updated.IntervalSeconds)
})
}
func TestDeleteAlertRule(t *testing.T) {
@@ -1248,6 +1409,62 @@ func TestDeleteAlertRule(t *testing.T) {
require.Empty(t, deletes)
})
})
// NoGroup-specific behaviors
t.Run("deleting a NoGroup rule removes only that rule", func(t *testing.T) {
service, ruleStore, provenanceStore, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil }
// create two NoGroup rules in the same namespace (distinct sentinel groups)
r1 := createNoGroupRule("nogroup-del-1", orgID, "my-namespace")
r2 := createNoGroupRule("nogroup-del-2", orgID, "my-namespace")
_, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{r1, r2})
require.NoError(t, err)
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &r1, orgID, models.ProvenanceNone))
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &r2, orgID, models.ProvenanceNone))
err = service.DeleteAlertRule(context.Background(), u, r1.UID, models.ProvenanceNone)
require.NoError(t, err)
deletes := getDeleteQueries(ruleStore)
require.Len(t, deletes, 1)
uids := deletes[0].Params[3].([]string)
require.Contains(t, uids, r1.UID)
// verify r2 remains in store
remaining := ruleStore.Rules[orgID]
require.Len(t, remaining, 1)
require.Equal(t, r2.UID, remaining[0].UID)
// and its sentinel group remains intact
require.True(t, models.IsNoGroupRuleGroup(remaining[0].RuleGroup))
})
t.Run("when user cannot write all rules, deleting a NoGroup rule authorizes and succeeds", func(t *testing.T) {
service, ruleStore, provenanceStore, ac := initService(t)
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return false, nil }
r := createNoGroupRule("nogroup-del-auth", orgID, "my-namespace")
_, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{r})
require.NoError(t, err)
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &r, orgID, models.ProvenanceNone))
ac.AuthorizeRuleChangesFunc = func(ctx context.Context, user identity.Requester, change *store.GroupDelta) error {
// expect single delete and affected group contains exactly the rule
require.Len(t, change.Delete, 1)
require.Contains(t, change.AffectedGroups, change.GroupKey)
require.Len(t, change.AffectedGroups[change.GroupKey], 1)
require.Equal(t, r.UID, change.AffectedGroups[change.GroupKey][0].UID)
return nil
}
err = service.DeleteAlertRule(context.Background(), u, r.UID, models.ProvenanceNone)
require.NoError(t, err)
deletes := getDeleteQueries(ruleStore)
require.Len(t, deletes, 1)
uids := deletes[0].Params[3].([]string)
require.Contains(t, uids, r.UID)
})
}
func TestGetAlertRule(t *testing.T) {
@@ -1420,6 +1637,52 @@ func TestGetRuleGroup(t *testing.T) {
assert.Len(t, ac.Calls, 1)
assert.Equal(t, "CanReadAllRules", ac.Calls[0].Method)
})
// NoGroup-specific behaviors
// A NoGroup rule should be returned as a one-rule group addressed by its sentinel group title
t.Run("should return NoGroup rule group with exactly one rule", func(t *testing.T) {
service, ruleStore, _, ac := initService(t)
ac.CanReadAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil }
rule := createNoGroupRule("nogroup-rule-1", orgID, "my-namespace")
ruleStore.Rules = map[int64][]*models.AlertRule{
orgID: {&rule},
}
group, err := service.GetRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(group.Title))
require.Equal(t, rule.NamespaceUID, group.FolderUID)
require.Equal(t, rule.IntervalSeconds, group.Interval)
require.Len(t, group.Rules, 1)
require.Equal(t, rule.UID, group.Rules[0].UID)
})
// Multiple NoGroup rules in the same namespace must produce separate sentinel groups
t.Run("should return distinct NoGroup groups for multiple rules", func(t *testing.T) {
service, ruleStore, _, ac := initService(t)
ac.CanReadAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil }
rule1 := createNoGroupRule("nogroup-rule-a", orgID, "my-namespace")
rule2 := createNoGroupRule("nogroup-rule-b", orgID, "my-namespace")
require.NotEqual(t, rule1.RuleGroup, rule2.RuleGroup)
ruleStore.Rules = map[int64][]*models.AlertRule{
orgID: {&rule1, &rule2},
}
group1, err := service.GetRuleGroup(context.Background(), u, rule1.NamespaceUID, rule1.RuleGroup)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(group1.Title))
require.Len(t, group1.Rules, 1)
require.Equal(t, rule1.UID, group1.Rules[0].UID)
group2, err := service.GetRuleGroup(context.Background(), u, rule2.NamespaceUID, rule2.RuleGroup)
require.NoError(t, err)
require.True(t, models.IsNoGroupRuleGroup(group2.Title))
require.Len(t, group2.Rules, 1)
require.Equal(t, rule2.UID, group2.Rules[0].UID)
})
}
func TestListAlertRules(t *testing.T) {
@@ -1761,6 +2024,36 @@ func TestReplaceGroup(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "new", rule.Metadata.PrometheusStyleRule.OriginalRuleDefinition)
})
// NoGroup rule group should not allow more than one rule
t.Run("should reject multiple rules in a NoGroup rule group", func(t *testing.T) {
service, _, _, _ := initServiceWithData(t)
// Build a NoGroup group title and attempt to place 2 rules under it
group := createNoGroupRuleGroup("nogroup-multi", orgID, "my-namespace")
second := createNoGroupRule("nogroup-second", orgID, "my-namespace")
// Ensure the second rule is assigned to the same sentinel group
second.RuleGroup = group.Title
group.Rules = append(group.Rules, second)
err := service.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceNone)
require.Error(t, err)
require.ErrorContains(t, err, "cannot be used for rule groups with multiple rules")
})
t.Run("should reject changing the group name in a NoGroup rule group", func(t *testing.T) {
service, store, _, _ := initServiceWithData(t)
// Create a NoGroup rule and attempt to change its group name
groupSeed := createNoGroupRuleGroup("nogroup-multi", orgID, "my-namespace")
store.Rules[orgID] = []*models.AlertRule{models.CopyRule(&groupSeed.Rules[0])}
// change the group name away from the sentinel value
groupSeed.Title = "some-other-group" // not the sentinel group name
err := service.ReplaceRuleGroup(context.Background(), u, groupSeed, models.ProvenanceNone)
require.Error(t, err)
require.ErrorContains(t, err, "cannot move rule out of this group")
})
}
func TestDeleteRuleGroup(t *testing.T) {
@@ -2162,6 +2455,56 @@ func dummyRule(title string, orgID int64) models.AlertRule {
return createTestRule(title, "my-cool-group", orgID, "my-namespace")
}
func createNoGroupRuleGroup(title string, orgID int64, namespace string) models.AlertRuleGroup {
uid := util.GenerateShortUID()
group, err := models.NewNoGroupRuleGroup(uid)
if err != nil {
panic(fmt.Sprintf("failed to create NoGroupRuleGroup: %v", err))
}
return models.AlertRuleGroup{
Title: group.String(),
Interval: 60,
FolderUID: namespace,
Rules: []models.AlertRule{
createNoGroupRule(title, orgID, namespace),
},
}
}
func createNoGroupRule(title string, orgID int64, namespace string) models.AlertRule {
uid := util.GenerateShortUID()
group, err := models.NewNoGroupRuleGroup(uid)
if err != nil {
panic(fmt.Sprintf("failed to create NoGroupRuleGroup: %v", err))
}
return models.AlertRule{
UID: uid,
OrgID: orgID,
Title: title,
Condition: "A",
Version: 1,
IntervalSeconds: 60,
Data: []models.AlertQuery{
{
RefID: "A",
Model: json.RawMessage("{}"),
DatasourceUID: expr.DatasourceUID,
RelativeTimeRange: models.RelativeTimeRange{
From: models.Duration(60),
To: models.Duration(0),
},
},
},
NamespaceUID: namespace,
RuleGroup: group.String(),
For: time.Second * 60,
NoDataState: models.OK,
ExecErrState: models.OkErrState,
}
}
func createTestRule(title string, groupTitle string, orgID int64, namespace string) models.AlertRule {
return models.AlertRule{
OrgID: orgID,
@@ -2225,3 +2568,77 @@ func initService(t *testing.T) (*AlertRuleService, *fakes.RuleStore, *fakes.Fake
return service, ruleStore, provenanceStore, ac
}
// func TestNoGroupRuleGroupIntervalHandling(t *testing.T) {
// orgID := rand.Int63()
// u := &user.SignedInUser{OrgID: orgID, UserUID: util.GenerateShortUID()}
// t.Run("UpdateRuleGroup with NoGroupRuleGroup", func(t *testing.T) {
// t.Run("should allow interval updates for NoGroupRuleGroup via UpdateRuleGroup", func(t *testing.T) {
// service, store, _, ac := initService(t)
// ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
// return true, nil
// }
// // pre populate a rule with NoGroupRuleGroup
// rule := createNoGroupRule("test-rule", orgID, "my-namespace")
// store.Rules[orgID] = []*models.AlertRule{&rule} // Pre-populate the store with the rule
// // Update the rule with a new interval via UpdateRuleGroup
// newInterval := int64(120)
// createdRule, _, err := service.GetAlertRule(context.Background(), u, rule.UID)
// require.NoError(t, err)
// err = service.UpdateRuleGroup(context.Background(), u, createdRule.NamespaceUID, createdRule.RuleGroup, newInterval)
// require.NoError(t, err)
// updatedRule, _, err := service.GetAlertRule(context.Background(), u, createdRule.UID)
// require.NoError(t, err)
// assert.Equal(t, newInterval, updatedRule.IntervalSeconds, "Rule interval should be updated for NoGroupRuleGroup")
// })
// // t.Run("should preserve group interval for normal groups", func(t *testing.T) {
// // service, _, _, ac := initService(t)
// // ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
// // return true, nil
// // }
// // // Create a rule in a normal group
// // groupInterval := int64(90)
// // rule := createTestRule("test-rule", "normal-group", orgID, "my-namespace")
// // rule2 := createTestRule("test-rule-2", "normal-group", orgID, "my-namespace")
// // rule2.IntervalSeconds = groupInterval // Set the group interval
// // rule.IntervalSeconds = groupInterval
// // createdRule, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
// // require.NoError(t, err)
// // createdRule2, err := service.CreateAlertRule(context.Background(), u, rule2, models.ProvenanceNone)
// // require.NoError(t, err)
// // // Try to update the rule with a different interval
// // createdRule.IntervalSeconds = 120
// // updatedRule, err := service.UpdateAlertRule(context.Background(), u, createdRule, models.ProvenanceNone)
// // require.NoError(t, err)
// // assert.Equal(t, int64(120), updatedRule.IntervalSeconds, "Rule interval should be changed for all rules in normal groups")
// // updatedRule2, _, err := service.GetAlertRule(context.Background(), u, createdRule2.UID)
// // require.NoError(t, err)
// // assert.Equal(t, int64(120), updatedRule2.IntervalSeconds, "All rules in the same group should have the same interval after update")
// // })
// })
// t.Run("GetRuleGroup with a NoGroupRuleGroup", func(t *testing.T) {
// t.Run("should allow retrieval of sentinel group", func(t *testing.T) {
// service, store, _, ac := initService(t)
// ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
// return true, nil
// }
// // pre populate a rule with NoGroupRuleGroup
// rule := createNoGroupRule("test-rule", orgID, "my-namespace")
// store.Rules[orgID] = []*models.AlertRule{&rule} // Pre-populate the store with the rule
// noGroupGroup, err := service.GetRuleGroup(context.Background(), u, "my-namespace", rule.RuleGroup)
// require.NoError(t, err)
// assert.Equal(t, rule.RuleGroup, noGroupGroup.Title, "NoGroupRuleGroup should be retrievable by its group title")
// assert.Len(t, noGroupGroup.Rules, 1, "NoGroupRuleGroup should contain only the one rule")
// })
// })
// }
@@ -105,6 +105,11 @@ func (sch *schedule) buildSequence(groupKey groupKey, groupItems []readyToRunIte
}
func (sch *schedule) shouldEvaluateSequentially(groupItems []readyToRunItem) bool {
// the no group group shouldn't be evaluated sequentially
if len(groupItems) > 0 && models.IsNoGroupRuleGroup(groupItems[0].rule.RuleGroup) {
return false
}
// if jitter by rule is enabled, we can't evaluate rules sequentially
if sch.jitterEvaluations == JitterByRule {
return false
+117 -86
View File
@@ -782,91 +782,10 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
// 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")
if query.OrgID >= 0 {
q = q.Where("org_id = ?", query.OrgID)
q, groupsSet, err := st.buildListAlertRulesQuery(sess, query)
if err != nil {
return err
}
if query.DashboardUID != "" {
q = q.Where("dashboard_uid = ?", query.DashboardUID)
if query.PanelID != 0 {
q = q.Where("panel_id = ?", query.PanelID)
}
}
if len(query.NamespaceUIDs) > 0 {
args, in := getINSubQueryArgs(query.NamespaceUIDs)
q = q.Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Join(in, ",")), args...)
}
if len(query.RuleUIDs) > 0 {
args, in := getINSubQueryArgs(query.RuleUIDs)
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
}
var groupsMap map[string]struct{}
if len(query.RuleGroups) > 0 {
groupsMap = make(map[string]struct{})
args, in := getINSubQueryArgs(query.RuleGroups)
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(in, ",")), args...)
for _, group := range query.RuleGroups {
groupsMap[group] = struct{}{}
}
}
if query.ReceiverName != "" {
q, err = st.filterByContentInNotificationSettings(query.ReceiverName, q)
if err != nil {
return err
}
}
if query.TimeIntervalName != "" {
q, err = st.filterByContentInNotificationSettings(query.TimeIntervalName, q)
if err != nil {
return err
}
}
if query.HasPrometheusRuleDefinition != nil {
q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q)
if err != nil {
return err
}
}
// 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)
@@ -879,7 +798,7 @@ func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.L
// Deserialize each rule separately in case any of them contain invalid JSON.
for rows.Next() {
converted, ok := st.handleRuleRow(rows, query, groupsMap)
converted, ok := st.handleRuleRow(rows, query, groupsSet)
if ok {
alertRules = append(alertRules, converted)
}
@@ -908,6 +827,118 @@ func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.L
return result, nextToken, err
}
func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.ListAlertRulesExtendedQuery) (q *xorm.Session, groupsSet map[string]struct{}, err error) {
q = sess.Table("alert_rule")
if query.OrgID >= 0 {
q = q.Where("org_id = ?", query.OrgID)
}
if query.DashboardUID != "" {
q = q.Where("dashboard_uid = ?", query.DashboardUID)
if query.PanelID != 0 {
q = q.Where("panel_id = ?", query.PanelID)
}
}
if len(query.NamespaceUIDs) > 0 {
args, in := getINSubQueryArgs(query.NamespaceUIDs)
q = q.Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Join(in, ",")), args...)
}
if len(query.RuleUIDs) > 0 {
args, in := getINSubQueryArgs(query.RuleUIDs)
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
}
var noGroupRuleGroupRuleUIDs []string
var realGroups []string
if len(query.RuleGroups) > 0 {
groupsSet = make(map[string]struct{})
for _, group := range query.RuleGroups {
if ngmodels.IsNoGroupRuleGroup(group) {
noGroupRuleGroup, err := ngmodels.ParseNoRuleGroup(group)
if err != nil {
return nil, groupsSet, fmt.Errorf("failed to parse rule group %q: %w", group, err)
}
noGroupRuleGroupRuleUIDs = append(noGroupRuleGroupRuleUIDs, noGroupRuleGroup.GetRuleUID())
} else {
realGroups = append(realGroups, group)
}
groupsSet[group] = struct{}{}
}
switch {
// all real rule groups,
case len(realGroups) > 0 && len(noGroupRuleGroupRuleUIDs) == 0:
groupArgs, groupIn := getINSubQueryArgs(realGroups)
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(groupIn, ",")), groupArgs...)
// all no-group rule groups
case len(realGroups) == 0 && len(noGroupRuleGroupRuleUIDs) > 0:
ruleUIDArgs, ruleUIDIn := getINSubQueryArgs(noGroupRuleGroupRuleUIDs)
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(ruleUIDIn, ",")), ruleUIDArgs...)
// mixed case, we need to perform the or
case len(realGroups) > 0 && len(noGroupRuleGroupRuleUIDs) > 0:
groupArgs, groupIn := getINSubQueryArgs(realGroups)
ruleUIDArgs, ruleUIDIn := getINSubQueryArgs(noGroupRuleGroupRuleUIDs)
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(groupIn, ",")), groupArgs...).Or(
fmt.Sprintf("uid IN (%s)", strings.Join(ruleUIDIn, ",")), ruleUIDArgs...,
)
}
}
if query.ReceiverName != "" {
q, err = st.filterByContentInNotificationSettings(query.ReceiverName, q)
if err != nil {
return nil, groupsSet, err
}
}
if query.TimeIntervalName != "" {
q, err = st.filterByContentInNotificationSettings(query.TimeIntervalName, q)
if err != nil {
return nil, groupsSet, err
}
}
if query.HasPrometheusRuleDefinition != nil {
q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q)
if err != nil {
return nil, groupsSet, err
}
}
// 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 nil, groupsSet, 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 nil, groupsSet, 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)
}
return q, groupsSet, nil
}
func (st DBstore) handleRuleRow(rows *xorm.Rows, query *ngmodels.ListAlertRulesExtendedQuery, groupsSet map[string]struct{}) (*ngmodels.AlertRule, bool) {
rule := new(alertRule)
err := rows.Scan(rule)
@@ -1217,7 +1248,7 @@ func (st DBstore) validateAlertRule(alertRule ngmodels.AlertRule) error {
}
// enforce max rule group name length.
if len(alertRule.RuleGroup) > AlertRuleMaxRuleGroupNameLength {
if len(alertRule.RuleGroup) > AlertRuleMaxRuleGroupNameLength && !ngmodels.IsNoGroupRuleGroup(alertRule.RuleGroup) {
return fmt.Errorf("%w: rule group name length should not be greater than %d", ngmodels.ErrAlertRuleFailedValidation, AlertRuleMaxRuleGroupNameLength)
}
+16 -2
View File
@@ -31,7 +31,6 @@ func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, e
NamespaceUID: ar.NamespaceUID,
DashboardUID: ar.DashboardUID,
PanelID: ar.PanelID,
RuleGroup: ar.RuleGroup,
RuleGroupIndex: ar.RuleGroupIndex,
For: ar.For,
KeepFiringFor: ar.KeepFiringFor,
@@ -39,6 +38,16 @@ func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, e
MissingSeriesEvalsToResolve: ar.MissingSeriesEvalsToResolve,
}
if ar.RuleGroup == "" {
noGroupRuleGroup, err := models.NewNoGroupRuleGroup(ar.UID)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to create no group rule group: %w", err)
}
result.RuleGroup = noGroupRuleGroup.String()
} else {
result.RuleGroup = ar.RuleGroup
}
if ar.UpdatedBy != nil {
result.UpdatedBy = util.Pointer(models.UserUID(*ar.UpdatedBy))
}
@@ -121,7 +130,6 @@ func alertRuleFromModelsAlertRule(ar models.AlertRule) (alertRule, error) {
NamespaceUID: ar.NamespaceUID,
DashboardUID: ar.DashboardUID,
PanelID: ar.PanelID,
RuleGroup: ar.RuleGroup,
RuleGroupIndex: ar.RuleGroupIndex,
NoDataState: ar.NoDataState.String(),
ExecErrState: ar.ExecErrState.String(),
@@ -131,6 +139,12 @@ func alertRuleFromModelsAlertRule(ar models.AlertRule) (alertRule, error) {
MissingSeriesEvalsToResolve: ar.MissingSeriesEvalsToResolve,
}
if models.IsNoGroupRuleGroup(ar.RuleGroup) {
result.RuleGroup = ""
} else {
result.RuleGroup = ar.RuleGroup
}
if ar.UpdatedBy != nil {
result.UpdatedBy = util.Pointer(string(*ar.UpdatedBy))
}
+21
View File
@@ -42,6 +42,27 @@ func TestAlertRuleToModelsAlertRule(t *testing.T) {
require.NoError(t, err)
require.Equal(t, ngmodels.ErrorErrState, converted.ExecErrState)
})
t.Run("should handle NoGroup rules properly", func(t *testing.T) {
rule, err := alertRuleFromModelsAlertRule(g.Generate())
require.NoError(t, err)
rule.RuleGroup = ""
projectedRuleGroup, err := ngmodels.NewNoGroupRuleGroup(rule.UID)
require.NoError(t, err)
converted, err := alertRuleToModelsAlertRule(rule, &logtest.Fake{})
require.NoError(t, err)
require.Equal(t, projectedRuleGroup.String(), converted.RuleGroup)
clone, err := alertRuleFromModelsAlertRule(converted)
require.NoError(t, err)
require.Equal(t, rule, clone)
require.Empty(t, clone.RuleGroup)
converted2, err := alertRuleToModelsAlertRule(clone, &logtest.Fake{})
require.NoError(t, err)
require.Equal(t, converted, converted2)
})
}
func TestAlertRuleVersionToAlertRule(t *testing.T) {
+2 -2
View File
@@ -179,7 +179,7 @@ func UpdateCalculatedRuleFields(ch *GroupDelta) *GroupDelta {
}
var toUpdate []RuleDelta
for groupKey, rules := range ch.AffectedGroups {
if groupKey != ch.GroupKey {
if groupKey != ch.GroupKey && !models.IsNoGroupRuleGroup(groupKey.RuleGroup) {
rules.SortByGroupIndex()
}
idx := 1
@@ -191,7 +191,7 @@ func UpdateCalculatedRuleFields(ch *GroupDelta) *GroupDelta {
Existing: rule,
New: rule,
}
if groupKey != ch.GroupKey {
if groupKey != ch.GroupKey && !models.IsNoGroupRuleGroup(groupKey.RuleGroup) {
if rule.RuleGroupIndex != idx {
upd.New = rule.Copy()
upd.New.RuleGroupIndex = idx