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
@@ -128,6 +128,10 @@ func convertToK8sResource(
return nil, fmt.Errorf("failed to get metadata: %w", err)
}
meta.SetFolder(rule.NamespaceUID)
// Keep metadata label in sync with folder annotation for downstream consumers
if rule.NamespaceUID != "" {
k8sRule.Labels[model.FolderLabelKey] = rule.NamespaceUID
}
if rule.UpdatedBy != nil {
meta.SetUpdatedBy(string(*rule.UpdatedBy))
k8sRule.SetUpdatedBy(string(*rule.UpdatedBy))
@@ -76,6 +76,10 @@ func convertToK8sResource(
return nil, fmt.Errorf("failed to get metadata: %w", err)
}
meta.SetFolder(rule.NamespaceUID)
// Keep metadata label in sync with folder annotation for downstream consumers
if rule.NamespaceUID != "" {
k8sRule.Labels[model.FolderLabelKey] = rule.NamespaceUID
}
if rule.UpdatedBy != nil {
meta.SetUpdatedBy(string(*rule.UpdatedBy))
k8sRule.SetUpdatedBy(string(*rule.UpdatedBy))
@@ -104,7 +104,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptio
return obj, err
}
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, _ rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) {
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
@@ -114,6 +114,11 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, _ rest.V
if err != nil {
return nil, err
}
if createValidation != nil {
if err := createValidation(ctx, obj); err != nil {
return nil, err
}
}
p, ok := obj.(*model.RecordingRule)
if !ok {
+64 -5
View File
@@ -14,13 +14,17 @@ import (
"github.com/grafana/grafana/apps/alerting/rules/pkg/apis"
rulesApp "github.com/grafana/grafana/apps/alerting/rules/pkg/app"
rulesAppConfig "github.com/grafana/grafana/apps/alerting/rules/pkg/app/config"
"github.com/grafana/grafana/pkg/apimachinery/identity"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules/alertrule"
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules/recordingrule"
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
reqns "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/ngalert"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/setting"
)
@@ -50,11 +54,66 @@ func RegisterAppInstaller(
ng: ng,
}
provider := simple.NewAppProvider(apis.LocalManifest(), nil, rulesApp.New)
appSpecificConfig := rulesAppConfig.RuntimeConfig{
// Validate folder existence using the folder service
FolderValidator: func(ctx context.Context, folderUID string) (bool, error) {
if folderUID == "" {
return false, nil
}
orgID, err := reqns.OrgIDForList(ctx)
user, _ := identity.GetRequester(ctx)
if (err != nil || orgID < 1) && user != nil {
orgID = user.GetOrgID()
}
if user == nil || orgID < 1 {
// If we can't resolve identity/org in this context, don't block creation based on existence
return true, nil
}
// Use the RuleStore to check namespace (folder) visibility
_, err = ng.Api.RuleStore.GetNamespaceByUID(ctx, folderUID, orgID, user)
if err != nil {
return false, nil
}
return true, nil
},
BaseEvaluationInterval: ng.Cfg.UnifiedAlerting.BaseInterval,
ReservedLabelKeys: ngmodels.LabelsUserCannotSpecify,
// Validate that the configured notification receiver exists in the Alertmanager config
NotificationSettingsValidator: func(ctx context.Context, receiver string) (bool, error) {
if receiver == "" {
return false, nil
}
orgID, err := reqns.OrgIDForList(ctx)
if err != nil || orgID < 1 {
if user, _ := identity.GetRequester(ctx); user != nil {
orgID = user.GetOrgID()
}
}
if orgID < 1 {
// Without org context, skip validation rather than block
return true, nil
}
provider := notifier.NewCachedNotificationSettingsValidationService(ng.Api.AlertingStore)
vd, err := provider.Validator(ctx, orgID)
if err != nil {
log.New("alerting.rules.app").Error("failed to create notification settings validator", "error", err)
// If we cannot build a validator, don't block admission
return true, nil
}
// Only validate receiver presence; construct minimal settings
if err := vd.Validate(ngmodels.NotificationSettings{Receiver: receiver}); err != nil {
return false, nil
}
return true, nil
},
}
provider := simple.NewAppProvider(apis.LocalManifest(), appSpecificConfig, rulesApp.New)
appConfig := app.Config{
KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method
ManifestData: *apis.LocalManifest().ManifestData,
KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method
ManifestData: *apis.LocalManifest().ManifestData,
SpecificConfig: appSpecificConfig,
}
i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, &apis.GoTypeAssociator{})
@@ -81,7 +140,7 @@ func (a *AlertingRulesAppInstaller) GetAuthorizer() authorizer.Authorizer {
}
func (a *AlertingRulesAppInstaller) GetLegacyStorage(gvr schema.GroupVersionResource) grafanarest.Storage {
namespacer := request.GetNamespaceMapper(a.cfg)
namespacer := reqns.GetNamespaceMapper(a.cfg)
switch gvr {
case recordingrule.ResourceInfo.GroupVersionResource():
return recordingrule.NewStorage(*a.ng.Api.AlertRules, namespacer)
@@ -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)
})
}