From 2780b5905017f0192153400b381107e3b10580e2 Mon Sep 17 00:00:00 2001 From: Axel Christ Date: Thu, 17 Feb 2022 18:47:49 +0100 Subject: [PATCH] Implement SharedFieldIndexer SharedFieldIndexer allows sharing registrations and field indexations for different types, fields and functions. Created test cases for the most relevant scenarios. --- clientutils/fieldindexer.go | 161 ++++++++++++++++++++++++ clientutils/fieldindexer_test.go | 160 +++++++++++++++++++++++ mock/controller-runtime/client/doc.go | 9 +- mock/controller-runtime/client/funcs.go | 64 ++++++++++ mock/controller-runtime/client/mocks.go | 39 +++++- 5 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 clientutils/fieldindexer.go create mode 100644 clientutils/fieldindexer_test.go create mode 100644 mock/controller-runtime/client/funcs.go diff --git a/clientutils/fieldindexer.go b/clientutils/fieldindexer.go new file mode 100644 index 0000000..e4a5ac1 --- /dev/null +++ b/clientutils/fieldindexer.go @@ -0,0 +1,161 @@ +// Copyright 2022 OnMetal authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package clientutils + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +// SharedFieldIndexer allows registering and calling field index functions shared by different users. +type SharedFieldIndexer struct { + indexer client.FieldIndexer + *sharedFieldIndexerMap +} + +// NewSharedFieldIndexer creates a new SharedFieldIndexer. +func NewSharedFieldIndexer(indexer client.FieldIndexer, scheme *runtime.Scheme) *SharedFieldIndexer { + return &SharedFieldIndexer{ + indexer: indexer, + sharedFieldIndexerMap: newSharedFieldIndexerMap(scheme), + } +} + +// Register registers the client.IndexerFunc for the given client.Object and field. +func (s *SharedFieldIndexer) Register(obj client.Object, field string, extractValue client.IndexerFunc) error { + updated, err := s.setIfNotPresent(obj, field, extractValue) + if err != nil { + return err + } + if !updated { + return fmt.Errorf("indexer for type %T field %s already registered", obj, field) + } + return nil +} + +// MustRegister registers the client.IndexerFunc for the given client.Object and field. +func (s *SharedFieldIndexer) MustRegister(obj client.Object, field string, extractValue client.IndexerFunc) { + utilruntime.Must(s.Register(obj, field, extractValue)) +} + +// IndexField calls a registered client.IndexerFunc for the given client.Object and field. +// If the object / field is unknown or its GVK could not be determined, it errors. +func (s *SharedFieldIndexer) IndexField(ctx context.Context, obj client.Object, field string) error { + entry, err := s.get(obj, field) + if err != nil { + return err + } + + if entry == nil { + return fmt.Errorf("unknown field %s for type %T", field, obj) + } + if entry.initialized { + return nil + } + if err := s.indexer.IndexField(ctx, obj, field, entry.extractValue); err != nil { + return err + } + entry.initialized = true + return nil +} + +type sharedFieldIndexerMap struct { + scheme *runtime.Scheme + unstructured *specificSharedFieldIndexerMap + metadata *specificSharedFieldIndexerMap + structured *specificSharedFieldIndexerMap +} + +func newSharedFieldIndexerMap(scheme *runtime.Scheme) *sharedFieldIndexerMap { + return &sharedFieldIndexerMap{ + scheme: scheme, + unstructured: newSpecificSharedFieldIndexerMap(), + metadata: newSpecificSharedFieldIndexerMap(), + structured: newSpecificSharedFieldIndexerMap(), + } +} + +type mapEntry struct { + initialized bool + extractValue client.IndexerFunc +} + +type specificSharedFieldIndexerMap struct { + gvkToNameToEntry map[schema.GroupVersionKind]map[string]*mapEntry +} + +func newSpecificSharedFieldIndexerMap() *specificSharedFieldIndexerMap { + return &specificSharedFieldIndexerMap{gvkToNameToEntry: make(map[schema.GroupVersionKind]map[string]*mapEntry)} +} + +func (s *specificSharedFieldIndexerMap) get(gvk schema.GroupVersionKind, name string) *mapEntry { + return s.gvkToNameToEntry[gvk][name] +} + +func (s *specificSharedFieldIndexerMap) setIfNotPresent(gvk schema.GroupVersionKind, name string, extractValue client.IndexerFunc) (updated bool) { + nameToEntry := s.gvkToNameToEntry[gvk] + if nameToEntry == nil { + nameToEntry = make(map[string]*mapEntry) + s.gvkToNameToEntry[gvk] = nameToEntry + } + + if _, ok := nameToEntry[name]; ok { + return false + } + nameToEntry[name] = &mapEntry{extractValue: extractValue} + return true +} + +func (s *sharedFieldIndexerMap) mapFor(obj client.Object) (*specificSharedFieldIndexerMap, schema.GroupVersionKind, error) { + gvk, err := apiutil.GVKForObject(obj, s.scheme) + if err != nil { + return nil, schema.GroupVersionKind{}, err + } + + switch obj.(type) { + case *unstructured.Unstructured: + return s.unstructured, gvk, nil + case *metav1.PartialObjectMetadata: + return s.metadata, gvk, nil + default: + return s.structured, gvk, nil + } +} + +func (s *sharedFieldIndexerMap) get(obj client.Object, name string) (*mapEntry, error) { + m, gvk, err := s.mapFor(obj) + if err != nil { + return nil, err + } + + return m.get(gvk, name), nil +} + +func (s *sharedFieldIndexerMap) setIfNotPresent(obj client.Object, name string, extractValue client.IndexerFunc) (updated bool, err error) { + m, gvk, err := s.mapFor(obj) + if err != nil { + return false, err + } + + return m.setIfNotPresent(gvk, name, extractValue), nil +} diff --git a/clientutils/fieldindexer_test.go b/clientutils/fieldindexer_test.go new file mode 100644 index 0000000..b61b20f --- /dev/null +++ b/clientutils/fieldindexer_test.go @@ -0,0 +1,160 @@ +// Copyright 2021 OnMetal authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package clientutils + +import ( + "context" + + "github.com/golang/mock/gomock" + mockclient "github.com/onmetal/controller-utils/mock/controller-runtime/client" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("FieldIndexer", func() { + var ( + ctx context.Context + ctrl *gomock.Controller + ) + BeforeEach(func() { + ctx = context.Background() + ctrl = gomock.NewController(GinkgoT()) + }) + AfterEach(func() { + ctrl.Finish() + }) + + Context("SharedFieldIndexer", func() { + var ( + fieldIndexer *mockclient.MockFieldIndexer + ) + BeforeEach(func() { + fieldIndexer = mockclient.NewMockFieldIndexer(ctrl) + }) + + It("should register an indexer func and call it", func() { + f := mockclient.NewMockIndexerFunc(ctrl) + gomock.InOrder( + fieldIndexer.EXPECT().IndexField(ctx, &corev1.Pod{}, ".spec", gomock.Any()).Do( + func(ctx context.Context, obj client.Object, field string, f client.IndexerFunc) error { + f(obj) + return nil + }), + f.EXPECT().Call(&corev1.Pod{}).Times(1), + ) + + idx := NewSharedFieldIndexer(fieldIndexer, scheme.Scheme) + + Expect(idx.Register(&corev1.Pod{}, ".spec", f.Call)).To(Succeed()) + + Expect(idx.IndexField(ctx, &corev1.Pod{}, ".spec")).To(Succeed()) + }) + + It("should error if a field is indexed twice", func() { + f := mockclient.NewMockIndexerFunc(ctrl) + idx := NewSharedFieldIndexer(fieldIndexer, scheme.Scheme) + + Expect(idx.Register(&corev1.Pod{}, ".spec", f.Call)).To(Succeed()) + Expect(idx.Register(&corev1.Pod{}, ".spec", f.Call)).To(MatchError("indexer for type *v1.Pod field .spec already registered")) + }) + + It("should call the index function only once", func() { + f := mockclient.NewMockIndexerFunc(ctrl) + gomock.InOrder( + fieldIndexer.EXPECT().IndexField(ctx, &corev1.Pod{}, ".spec", gomock.Any()).Do( + func(ctx context.Context, obj client.Object, field string, f client.IndexerFunc) error { + f(obj) + return nil + }), + f.EXPECT().Call(&corev1.Pod{}).Times(1), + ) + + idx := NewSharedFieldIndexer(fieldIndexer, scheme.Scheme) + + Expect(idx.Register(&corev1.Pod{}, ".spec", f.Call)).To(Succeed()) + + Expect(idx.IndexField(ctx, &corev1.Pod{}, ".spec")).To(Succeed()) + Expect(idx.IndexField(ctx, &corev1.Pod{}, ".spec")).To(Succeed()) + Expect(idx.IndexField(ctx, &corev1.Pod{}, ".spec")).To(Succeed()) + }) + + It("should work with unstructured objects", func() { + pod := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + }, + } + f := mockclient.NewMockIndexerFunc(ctrl) + gomock.InOrder( + fieldIndexer.EXPECT().IndexField(ctx, pod, ".spec", gomock.Any()).Do( + func(ctx context.Context, obj client.Object, field string, f client.IndexerFunc) error { + f(obj) + return nil + }), + f.EXPECT().Call(pod).Times(1), + ) + + idx := NewSharedFieldIndexer(fieldIndexer, scheme.Scheme) + + Expect(idx.Register(pod, ".spec", f.Call)).To(Succeed()) + + Expect(idx.IndexField(ctx, pod, ".spec")).To(Succeed()) + }) + + It("should work with partial object metadata", func() { + pod := &metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, + } + f := mockclient.NewMockIndexerFunc(ctrl) + gomock.InOrder( + fieldIndexer.EXPECT().IndexField(ctx, pod, ".spec", gomock.Any()).Do( + func(ctx context.Context, obj client.Object, field string, f client.IndexerFunc) error { + f(obj) + return nil + }), + f.EXPECT().Call(pod).Times(1), + ) + + idx := NewSharedFieldIndexer(fieldIndexer, scheme.Scheme) + + Expect(idx.Register(pod, ".spec", f.Call)).To(Succeed()) + + Expect(idx.IndexField(ctx, pod, ".spec")).To(Succeed()) + }) + + It("should error if the gvk could not be obtained", func() { + f := mockclient.NewMockIndexerFunc(ctrl) + idx := NewSharedFieldIndexer(fieldIndexer, scheme.Scheme) + + type CustomObject struct{ corev1.Pod } + + Expect(idx.Register(&CustomObject{}, ".spec", f.Call)).To(HaveOccurred()) + }) + + It("should error if the indexed function is unknown", func() { + idx := NewSharedFieldIndexer(fieldIndexer, scheme.Scheme) + Expect(idx.IndexField(ctx, &corev1.Pod{}, "unknown")).To(HaveOccurred()) + }) + }) +}) diff --git a/mock/controller-runtime/client/doc.go b/mock/controller-runtime/client/doc.go index 126d9bd..f23801d 100644 --- a/mock/controller-runtime/client/doc.go +++ b/mock/controller-runtime/client/doc.go @@ -13,5 +13,12 @@ // limitations under the License. // Package client contains mocks for controller-runtime's client package. -//go:generate go run github.com/golang/mock/mockgen -copyright_file ../../../hack/boilerplate.go.txt -package client -destination mocks.go sigs.k8s.io/controller-runtime/pkg/client Client +//go:generate go run github.com/golang/mock/mockgen -copyright_file ../../../hack/boilerplate.go.txt -package client -destination mocks.go sigs.k8s.io/controller-runtime/pkg/client Client,FieldIndexer +//go:generate go run github.com/golang/mock/mockgen -copyright_file ../../../hack/boilerplate.go.txt -package client -destination funcs.go github.com/onmetal/controller-utils/mock/controller-runtime/client IndexerFunc package client + +import "sigs.k8s.io/controller-runtime/pkg/client" + +type IndexerFunc interface { + Call(object client.Object) []string +} diff --git a/mock/controller-runtime/client/funcs.go b/mock/controller-runtime/client/funcs.go new file mode 100644 index 0000000..14e3494 --- /dev/null +++ b/mock/controller-runtime/client/funcs.go @@ -0,0 +1,64 @@ +// // Copyright 2021 OnMetal authors +// // +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // +// // http://www.apache.org/licenses/LICENSE-2.0 +// // +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/onmetal/controller-utils/mock/controller-runtime/client (interfaces: IndexerFunc) + +// Package client is a generated GoMock package. +package client + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + client "sigs.k8s.io/controller-runtime/pkg/client" +) + +// MockIndexerFunc is a mock of IndexerFunc interface. +type MockIndexerFunc struct { + ctrl *gomock.Controller + recorder *MockIndexerFuncMockRecorder +} + +// MockIndexerFuncMockRecorder is the mock recorder for MockIndexerFunc. +type MockIndexerFuncMockRecorder struct { + mock *MockIndexerFunc +} + +// NewMockIndexerFunc creates a new mock instance. +func NewMockIndexerFunc(ctrl *gomock.Controller) *MockIndexerFunc { + mock := &MockIndexerFunc{ctrl: ctrl} + mock.recorder = &MockIndexerFuncMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIndexerFunc) EXPECT() *MockIndexerFuncMockRecorder { + return m.recorder +} + +// Call mocks base method. +func (m *MockIndexerFunc) Call(arg0 client.Object) []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Call", arg0) + ret0, _ := ret[0].([]string) + return ret0 +} + +// Call indicates an expected call of Call. +func (mr *MockIndexerFuncMockRecorder) Call(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Call", reflect.TypeOf((*MockIndexerFunc)(nil).Call), arg0) +} diff --git a/mock/controller-runtime/client/mocks.go b/mock/controller-runtime/client/mocks.go index f67f50d..bda420c 100644 --- a/mock/controller-runtime/client/mocks.go +++ b/mock/controller-runtime/client/mocks.go @@ -14,7 +14,7 @@ // // Code generated by MockGen. DO NOT EDIT. -// Source: sigs.k8s.io/controller-runtime/pkg/client (interfaces: Client) +// Source: sigs.k8s.io/controller-runtime/pkg/client (interfaces: Client,FieldIndexer) // Package client is a generated GoMock package. package client @@ -222,3 +222,40 @@ func (mr *MockClientMockRecorder) Update(arg0, arg1 interface{}, arg2 ...interfa varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockClient)(nil).Update), varargs...) } + +// MockFieldIndexer is a mock of FieldIndexer interface. +type MockFieldIndexer struct { + ctrl *gomock.Controller + recorder *MockFieldIndexerMockRecorder +} + +// MockFieldIndexerMockRecorder is the mock recorder for MockFieldIndexer. +type MockFieldIndexerMockRecorder struct { + mock *MockFieldIndexer +} + +// NewMockFieldIndexer creates a new mock instance. +func NewMockFieldIndexer(ctrl *gomock.Controller) *MockFieldIndexer { + mock := &MockFieldIndexer{ctrl: ctrl} + mock.recorder = &MockFieldIndexerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFieldIndexer) EXPECT() *MockFieldIndexerMockRecorder { + return m.recorder +} + +// IndexField mocks base method. +func (m *MockFieldIndexer) IndexField(arg0 context.Context, arg1 client.Object, arg2 string, arg3 client.IndexerFunc) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IndexField", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// IndexField indicates an expected call of IndexField. +func (mr *MockFieldIndexerMockRecorder) IndexField(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IndexField", reflect.TypeOf((*MockFieldIndexer)(nil).IndexField), arg0, arg1, arg2, arg3) +}