Alerting: Add admission hooks for rules app (#113429)

This adds validating admission hooks to enforce the requirements on AlertRules and RecordingRules that are currently enforced through the provisioning service and storage mechanisms in preparation of a consistent validation in both legacy storage and unified storage. It also adds a mutating admission hook to the app to ensure that folder annotations and folder labels are kept in sync so we can perform label-selector lists.
This commit is contained in:
Moustafa Baiou
2025-11-07 12:01:16 -05:00
committed by GitHub
parent ecc9e9257e
commit 1e1adafeec
22 changed files with 1144 additions and 16 deletions
@@ -461,7 +461,7 @@ func TestIntegrationCRUD(t *testing.T) {
}
created, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{})
require.ErrorContains(t, err, "invalid alert rule")
require.ErrorContains(t, err, "trigger interval must be a multiple of base evaluation interval")
require.Nil(t, created)
})
}
@@ -564,3 +564,148 @@ func TestIntegrationBasicAPI(t *testing.T) {
t.Logf("Got error: %s", err)
})
}
func TestIntegrationFolderLabelSyncAndValidation(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
ctx := context.Background()
helper := common.GetTestHelper(t)
client := common.NewAlertRuleClient(t, helper.Org1.Admin)
// Prepare two folders for label sync update scenario
common.CreateTestFolder(t, helper, "test-folder-a")
common.CreateTestFolder(t, helper, "test-folder-b")
baseGen := ngmodels.RuleGen.With(
ngmodels.RuleMuts.WithUniqueUID(),
ngmodels.RuleMuts.WithUniqueTitle(),
ngmodels.RuleMuts.WithNamespaceUID("test-folder-a"),
ngmodels.RuleMuts.WithGroupName("test-group"),
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
)
t.Run("should keep folder label in sync with folder annotation on create and update", func(t *testing.T) {
rule := baseGen.Generate()
alertRule := &v0alpha1.AlertRule{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
Annotations: map[string]string{
v0alpha1.FolderAnnotationKey: "test-folder-a",
},
},
Spec: v0alpha1.AlertRuleSpec{
Title: rule.Title,
Expressions: v0alpha1.AlertRuleExpressionMap{
"A": {
QueryType: util.Pointer(rule.Data[0].QueryType),
DatasourceUID: util.Pointer(v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID)),
Model: rule.Data[0].Model,
Source: util.Pointer(true),
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
},
},
},
Trigger: v0alpha1.AlertRuleIntervalTrigger{
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
},
NoDataState: string(rule.NoDataState),
ExecErrState: string(rule.ExecErrState),
},
}
created, err := client.Create(ctx, alertRule, v1.CreateOptions{})
require.NoError(t, err)
defer func() { _ = client.Delete(ctx, created.Name, v1.DeleteOptions{}) }()
// On create, metadata.labels[v0alpha1.FolderLabelKey] should mirror annotation
require.Equal(t, "test-folder-a", created.Labels[v0alpha1.FolderLabelKey])
// Update annotation to point to a different folder and ensure label follows
updated := created.Copy().(*v0alpha1.AlertRule)
if updated.Annotations == nil {
updated.Annotations = map[string]string{}
}
updated.Annotations[v0alpha1.FolderAnnotationKey] = "test-folder-b"
after, err := client.Update(ctx, updated, v1.UpdateOptions{})
require.NoError(t, err)
require.Equal(t, "test-folder-b", after.Annotations[v0alpha1.FolderAnnotationKey])
require.Equal(t, "test-folder-b", after.Labels[v0alpha1.FolderLabelKey])
})
t.Run("should fail to create rule without folder annotation", func(t *testing.T) {
rule := baseGen.Generate()
alertRule := &v0alpha1.AlertRule{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
Annotations: map[string]string{}, // missing grafana.app/folder
},
Spec: v0alpha1.AlertRuleSpec{
Title: rule.Title,
Expressions: v0alpha1.AlertRuleExpressionMap{
"A": {
QueryType: util.Pointer(rule.Data[0].QueryType),
DatasourceUID: util.Pointer(v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID)),
Model: rule.Data[0].Model,
Source: util.Pointer(true),
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
},
},
},
Trigger: v0alpha1.AlertRuleIntervalTrigger{
Interval: v0alpha1.AlertRulePromDuration("10s"),
},
NoDataState: "NoData",
ExecErrState: "Error",
},
}
created, err := client.Create(ctx, alertRule, v1.CreateOptions{})
require.Error(t, err)
require.Nil(t, created)
})
t.Run("should fail to create rule with group labels preset", func(t *testing.T) {
rule := baseGen.Generate()
alertRule := &v0alpha1.AlertRule{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
Annotations: map[string]string{
v0alpha1.FolderAnnotationKey: "test-folder-a",
},
Labels: map[string]string{
v0alpha1.GroupLabelKey: "some-group",
v0alpha1.GroupIndexLabelKey: "0",
},
},
Spec: v0alpha1.AlertRuleSpec{
Title: rule.Title,
Expressions: v0alpha1.AlertRuleExpressionMap{
"A": {
QueryType: util.Pointer(rule.Data[0].QueryType),
DatasourceUID: util.Pointer(v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID)),
Model: rule.Data[0].Model,
Source: util.Pointer(true),
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
},
},
},
Trigger: v0alpha1.AlertRuleIntervalTrigger{Interval: v0alpha1.AlertRulePromDuration("10s")},
NoDataState: "NoData",
ExecErrState: "Error",
},
}
created, err := client.Create(ctx, alertRule, v1.CreateOptions{})
require.Error(t, err)
require.Nil(t, created)
})
}
@@ -454,7 +454,7 @@ func TestIntegrationCRUD(t *testing.T) {
}
created, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{})
require.ErrorContains(t, err, "invalid alert rule")
require.ErrorContains(t, err, "trigger interval must be a multiple of base evaluation interval")
require.Nil(t, created)
})
}
@@ -557,3 +557,139 @@ func TestIntegrationBasicAPI(t *testing.T) {
t.Logf("Got error: %s", err)
})
}
func TestIntegrationFolderLabelSyncAndValidation(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
ctx := context.Background()
helper := common.GetTestHelper(t)
client := common.NewRecordingRuleClient(t, helper.Org1.Admin)
// Prepare two folders for label sync update scenario
common.CreateTestFolder(t, helper, "test-folder-a")
common.CreateTestFolder(t, helper, "test-folder-b")
baseGen := ngmodels.RuleGen.With(
ngmodels.RuleMuts.WithUniqueUID(),
ngmodels.RuleMuts.WithUniqueTitle(),
ngmodels.RuleMuts.WithNamespaceUID("test-folder-a"),
ngmodels.RuleMuts.WithGroupName("test-group"),
ngmodels.RuleMuts.WithAllRecordingRules(),
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
)
t.Run("should keep folder label in sync with folder annotation on create and update", func(t *testing.T) {
rule := baseGen.Generate()
recordingRule := &v0alpha1.RecordingRule{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
Annotations: map[string]string{
v0alpha1.FolderAnnotationKey: "test-folder-a",
},
},
Spec: v0alpha1.RecordingRuleSpec{
Title: rule.Title,
Metric: rule.Record.Metric,
Expressions: v0alpha1.RecordingRuleExpressionMap{
"A": {
QueryType: util.Pointer(rule.Data[0].QueryType),
DatasourceUID: util.Pointer(v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID)),
Model: rule.Data[0].Model,
Source: util.Pointer(true),
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
},
},
},
Trigger: v0alpha1.RecordingRuleIntervalTrigger{Interval: v0alpha1.RecordingRulePromDuration("10s")},
},
}
created, err := client.Create(ctx, recordingRule, v1.CreateOptions{})
require.NoError(t, err)
defer func() { _ = client.Delete(ctx, created.Name, v1.DeleteOptions{}) }()
// On create, metadata.labels[v0alpha1.FolderLabelKey] should mirror annotation
require.Equal(t, "test-folder-a", created.Labels[v0alpha1.FolderLabelKey])
updated := created.Copy().(*v0alpha1.RecordingRule)
if updated.Annotations == nil {
updated.Annotations = map[string]string{}
}
updated.Annotations[v0alpha1.FolderAnnotationKey] = "test-folder-b"
after, err := client.Update(ctx, updated, v1.UpdateOptions{})
require.NoError(t, err)
require.Equal(t, "test-folder-b", after.Annotations[v0alpha1.FolderAnnotationKey])
require.Equal(t, "test-folder-b", after.Labels[v0alpha1.FolderLabelKey])
})
t.Run("should fail to create recording rule without folder annotation", func(t *testing.T) {
rule := baseGen.Generate()
recordingRule := &v0alpha1.RecordingRule{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
Annotations: map[string]string{},
},
Spec: v0alpha1.RecordingRuleSpec{
Title: rule.Title,
Metric: rule.Record.Metric,
Expressions: v0alpha1.RecordingRuleExpressionMap{
"A": {
QueryType: util.Pointer(rule.Data[0].QueryType),
DatasourceUID: util.Pointer(v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID)),
Model: rule.Data[0].Model,
Source: util.Pointer(true),
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
},
},
},
Trigger: v0alpha1.RecordingRuleIntervalTrigger{Interval: v0alpha1.RecordingRulePromDuration("10s")},
},
}
created, err := client.Create(ctx, recordingRule, v1.CreateOptions{})
require.Error(t, err)
require.Nil(t, created)
})
t.Run("should fail to create rule with group labels preset", func(t *testing.T) {
rule := baseGen.Generate()
recordingRule := &v0alpha1.RecordingRule{
ObjectMeta: v1.ObjectMeta{
Namespace: "default",
Annotations: map[string]string{
v0alpha1.FolderAnnotationKey: "test-folder-a",
},
Labels: map[string]string{
v0alpha1.GroupLabelKey: "some-group",
v0alpha1.GroupIndexLabelKey: "0",
},
},
Spec: v0alpha1.RecordingRuleSpec{
Title: rule.Title,
Metric: rule.Record.Metric,
Expressions: v0alpha1.RecordingRuleExpressionMap{
"A": {
QueryType: util.Pointer(rule.Data[0].QueryType),
DatasourceUID: util.Pointer(v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID)),
Model: rule.Data[0].Model,
Source: util.Pointer(true),
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
},
},
},
Trigger: v0alpha1.RecordingRuleIntervalTrigger{Interval: v0alpha1.RecordingRulePromDuration("10s")},
},
}
created, err := client.Create(ctx, recordingRule, v1.CreateOptions{})
require.Error(t, err)
require.Nil(t, created)
})
}