From eea28172e0ce9f5798b42d74e749b47f7d85050a Mon Sep 17 00:00:00 2001 From: Matthew Jacobson Date: Tue, 10 Sep 2024 10:58:14 -0400 Subject: [PATCH] Alerting: Add field selectors for k8s receivers API (#93015) Add field selectors for k8s receivers API metadata.provenance spec.title --- .../v0alpha1/register.go | 26 ++++++ .../notifications/receiver/conversions.go | 6 +- .../notifications/receiver/legacy_storage.go | 4 +- .../notifications/receiver/storage.go | 24 ++++- .../notifications/receivers/receiver_test.go | 92 +++++++++++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) diff --git a/pkg/apis/alerting_notifications/v0alpha1/register.go b/pkg/apis/alerting_notifications/v0alpha1/register.go index 3e6b1d29125..d59b6fdd1da 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/register.go +++ b/pkg/apis/alerting_notifications/v0alpha1/register.go @@ -106,6 +106,22 @@ func AddKnownTypesGroup(scheme *runtime.Scheme, g schema.GroupVersion) error { return err } + err = scheme.AddFieldLabelConversionFunc( + ReceiverResourceInfo.GroupVersionKind(), + func(label, value string) (string, string, error) { + fieldSet := SelectableReceiverFields(&Receiver{}) + for key := range fieldSet { + if label == key { + return label, value, nil + } + } + return "", "", fmt.Errorf("field label not supported for %s: %s", scope.ScopeNodeResourceInfo.GroupVersionKind(), label) + }, + ) + if err != nil { + return err + } + return nil } @@ -119,6 +135,16 @@ func SelectableTimeIntervalsFields(obj *TimeInterval) fields.Set { }) } +func SelectableReceiverFields(obj *Receiver) fields.Set { + if obj == nil { + return nil + } + return generic.MergeFieldsSets(generic.ObjectMetaFieldsSet(&obj.ObjectMeta, false), fields.Set{ + "metadata.provenance": obj.GetProvenanceStatus(), + "spec.title": obj.Spec.Title, + }) +} + // Resource takes an unqualified resource and returns a Group qualified GroupResource func Resource(resource string) schema.GroupResource { return SchemeGroupVersion.WithResource(resource).GroupResource() diff --git a/pkg/registry/apis/alerting/notifications/receiver/conversions.go b/pkg/registry/apis/alerting/notifications/receiver/conversions.go index bb628b47f03..5b273ff7c2a 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/conversions.go +++ b/pkg/registry/apis/alerting/notifications/receiver/conversions.go @@ -4,6 +4,7 @@ import ( "maps" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" @@ -13,7 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" ) -func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, namespacer request.NamespaceMapper) (*model.ReceiverList, error) { +func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, namespacer request.NamespaceMapper, selector fields.Selector) (*model.ReceiverList, error) { result := &model.ReceiverList{ Items: make([]model.Receiver, 0, len(receivers)), } @@ -22,6 +23,9 @@ func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, namespac if err != nil { return nil, err } + if selector != nil && !selector.Empty() && !selector.Matches(model.SelectableReceiverFields(k8sResource)) { + continue + } result.Items = append(result.Items, *k8sResource) } return result, nil diff --git a/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go b/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go index a43e4f2f1af..6d5b22f7b51 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go +++ b/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go @@ -61,7 +61,7 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec return s.tableConverter.ConvertToTable(ctx, object, tableOptions) } -func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions) (runtime.Object, error) { +func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOptions) (runtime.Object, error) { orgId, err := request.OrgIDForList(ctx) if err != nil { return nil, err @@ -85,7 +85,7 @@ func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions return nil, err } - return convertToK8sResources(orgId, res, s.namespacer) + return convertToK8sResources(orgId, res, s.namespacer, opts.FieldSelector) } func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOptions) (runtime.Object, error) { diff --git a/pkg/registry/apis/alerting/notifications/receiver/storage.go b/pkg/registry/apis/alerting/notifications/receiver/storage.go index 9647bbd4e84..db2db4a7bbe 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/storage.go +++ b/pkg/registry/apis/alerting/notifications/receiver/storage.go @@ -1,11 +1,17 @@ package receiver import ( + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/generic" genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" "k8s.io/apiserver/pkg/registry/rest" + apistore "k8s.io/apiserver/pkg/storage" + model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -39,7 +45,7 @@ func NewStorage( s := &genericregistry.Store{ NewFunc: resourceInfo.NewFunc, NewListFunc: resourceInfo.NewListFunc, - PredicateFunc: grafanaregistry.Matcher, + PredicateFunc: Matcher, DefaultQualifiedResource: resourceInfo.GroupResource(), SingularQualifiedResource: resourceInfo.SingularGroupResource(), TableConvertor: legacyStore.tableConverter, @@ -55,3 +61,19 @@ func NewStorage( } return legacyStore, nil } + +func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + if s, ok := obj.(*model.Receiver); ok { + return s.Labels, model.SelectableReceiverFields(s), nil + } + return nil, nil, fmt.Errorf("object of type %T is not supported", obj) +} + +// Matcher returns a generic.SelectionPredicate that matches on label and field selectors. +func Matcher(label labels.Selector, field fields.Selector) apistore.SelectionPredicate { + return apistore.SelectionPredicate{ + Label: label, + Field: field, + GetAttrs: GetAttrs, + } +} diff --git a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go index 019e2feae78..713492c4f37 100644 --- a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go +++ b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go @@ -934,6 +934,98 @@ func TestIntegrationCRUD(t *testing.T) { }) } +func TestIntegrationReceiverListSelector(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().Receivers("default") + + require.NoError(t, err) + recv1 := &v0alpha1.Receiver{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.ReceiverSpec{ + Title: "test-receiver-1", + Integrations: []v0alpha1.Integration{ + createIntegration(t, "email"), + }, + }, + } + recv1, err = adminClient.Create(ctx, recv1, v1.CreateOptions{}) + require.NoError(t, err) + + recv2 := &v0alpha1.Receiver{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.ReceiverSpec{ + Title: "test-receiver-2", + Integrations: []v0alpha1.Integration{ + createIntegration(t, "email"), + }, + }, + } + recv2, err = adminClient.Create(ctx, recv2, v1.CreateOptions{}) + require.NoError(t, err) + + env := helper.GetEnv() + ac := acimpl.ProvideAccessControl(env.FeatureToggles, zanzana.NewNoopClient()) + db, err := store.ProvideDBStore(env.Cfg, env.FeatureToggles, env.SQLStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac) + require.NoError(t, err) + require.NoError(t, db.SetProvenance(ctx, &definitions.EmbeddedContactPoint{ + UID: *recv2.Spec.Integrations[0].Uid, + }, helper.Org1.Admin.Identity.GetOrgID(), "API")) + recv2, err = adminClient.Get(ctx, recv2.Name, v1.GetOptions{}) + + require.NoError(t, err) + + receivers, err := adminClient.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + require.Len(t, receivers.Items, 3) // Includes default. + + t.Run("should filter by receiver name", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: "spec.title=" + recv1.Spec.Title, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + require.Equal(t, recv1.Name, list.Items[0].Name) + }) + + t.Run("should filter by metadata name", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: "metadata.name=" + recv2.Name, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + require.Equal(t, recv2.Name, list.Items[0].Name) + }) + + t.Run("should filter by multiple filters", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: fmt.Sprintf("metadata.name=%s,metadata.provenance=%s", recv2.Name, "API"), + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + require.Equal(t, recv2.Name, list.Items[0].Name) + }) + + t.Run("should be empty when filter does not match", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: fmt.Sprintf("metadata.name=%s,metadata.provenance=%s", recv2.Name, "unknown"), + }) + require.NoError(t, err) + require.Empty(t, list.Items) + }) +} + func createIntegration(t *testing.T, integrationType string) v0alpha1.Integration { cfg, ok := notify.AllKnownConfigsForTesting[integrationType] require.Truef(t, ok, "no known config for integration type %s", integrationType)