diff --git a/pkg/controller/common/customize/manager_test.go b/pkg/controller/common/customize/manager_test.go index 0d2800bcea..8c874c98df 100644 --- a/pkg/controller/common/customize/manager_test.go +++ b/pkg/controller/common/customize/manager_test.go @@ -17,7 +17,6 @@ limitations under the License. package customize import ( - "metacontroller/pkg/internal/testutils" "reflect" "testing" @@ -26,6 +25,8 @@ import ( dynamicclientset "metacontroller/pkg/dynamic/clientset" dynamicinformer "metacontroller/pkg/dynamic/informer" + . "metacontroller/pkg/internal/testutils/hooks" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) @@ -34,29 +35,10 @@ var fakeEnqueueParent = func(obj interface{}) {} var dynClient = dynamicclientset.Clientset{} var dynInformers = dynamicinformer.SharedInformerFactory{} -type nilCustomizableController struct { -} - -func (cc *nilCustomizableController) GetCustomizeHook() *v1alpha1.Hook { - return nil -} - -type fakeCustomizableController struct { -} - -func (cc *fakeCustomizableController) GetCustomizeHook() *v1alpha1.Hook { - url := "fake" - return &v1alpha1.Hook{ - Webhook: &v1alpha1.Webhook{ - URL: &url, - }, - } -} - var customizeManagerWithNilController, _ = NewCustomizeManager( "test", fakeEnqueueParent, - &nilCustomizableController{}, + &NilCustomizableController{}, &dynClient, &dynInformers, make(common.InformerMap), @@ -68,7 +50,7 @@ var customizeManagerWithNilController, _ = NewCustomizeManager( var customizeManagerWithFakeController, _ = NewCustomizeManager( "test", fakeEnqueueParent, - &fakeCustomizableController{}, + &FakeCustomizableController{}, &dynClient, &dynInformers, make(common.InformerMap), @@ -106,7 +88,7 @@ func TestGetRelatedObject_requestResponse(t *testing.T) { }}, } - customizeManagerWithFakeController.customizeHook = testutils.NewHookExecutorStub(expectedResponse) + customizeManagerWithFakeController.customizeHook = NewHookExecutorStub(expectedResponse) parent := &unstructured.Unstructured{} parent.SetName("othertest") parent.SetGeneration(1) diff --git a/pkg/controller/common/manage_children.go b/pkg/controller/common/manage_children.go index da757c580f..571ad6da42 100644 --- a/pkg/controller/common/manage_children.go +++ b/pkg/controller/common/manage_children.go @@ -29,6 +29,7 @@ import ( dynamicapply "metacontroller/pkg/dynamic/apply" dynamicclientset "metacontroller/pkg/dynamic/clientset" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" utilerrors "k8s.io/apimachinery/pkg/util/errors" @@ -184,7 +185,12 @@ func deleteChildren(client *dynamicclientset.ResourceClient, parent *unstructure }, ) if err != nil { - errs = append(errs, fmt.Errorf("can't delete %v: %w", describeObject(obj), err)) + if apierrors.IsNotFound(err) { + // Swallow the error since there's no point retrying if the child is gone. + logging.Logger.Info("Failed to delete child, child object has been deleted", "parent", parent, "child", obj) + } else { + errs = append(errs, fmt.Errorf("can't delete %v: %w", describeObject(obj), err)) + } continue } } @@ -251,14 +257,28 @@ func updateChildren(client *dynamicclientset.ResourceClient, updateStrategy Chil }, ) if err != nil { - errs = append(errs, err) + if apierrors.IsNotFound(err) { + // Swallow the error since there's no point retrying if the child is gone. + logging.Logger.Info("Failed to delete child, child object has been deleted", "parent", parent, "child", obj) + } else { + errs = append(errs, err) + } continue } case v1alpha1.ChildUpdateInPlace, v1alpha1.ChildUpdateRollingInPlace: // Update the object in-place. logging.Logger.Info("Updating", "parent", parent, "child", obj, "reason", "Recreate update strategy selected") if _, err := client.Namespace(ns).Update(context.TODO(), newObj, metav1.UpdateOptions{}); err != nil { - errs = append(errs, err) + switch { + case apierrors.IsNotFound(err): + // Swallow the error since there's no point retrying if the child is gone. + logging.Logger.Info("Failed to update child, child object has been deleted", "parent", parent, "child", obj) + case apierrors.IsConflict(err): + // it is possible that the object was modified after this sync was started, ignore conflict since we will reconcile again + logging.Logger.Info("Failed to update child due to outdated resourceVersion", "parent", parent, "child", obj) + default: + errs = append(errs, err) + } continue } default: @@ -286,7 +306,12 @@ func updateChildren(client *dynamicclientset.ResourceClient, updateStrategy Chil obj.SetOwnerReferences(ownerRefs) if _, err := client.Namespace(ns).Create(context.TODO(), obj, metav1.CreateOptions{}); err != nil { - errs = append(errs, err) + if apierrors.IsAlreadyExists(err) { + // Swallow the error since there's no point retrying if the child already exists + logging.Logger.Info("Failed to create child, child object already exists", "parent", parent, "child", obj) + } else { + errs = append(errs, err) + } continue } } diff --git a/pkg/controller/common/manage_children_test.go b/pkg/controller/common/manage_children_test.go index 391ea1a9bb..627580e1e7 100644 --- a/pkg/controller/common/manage_children_test.go +++ b/pkg/controller/common/manage_children_test.go @@ -1,15 +1,60 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 common import ( + "metacontroller/pkg/apis/metacontroller/v1alpha1" + dynamicclientset "metacontroller/pkg/dynamic/clientset" + . "metacontroller/pkg/internal/testutils/common" + . "metacontroller/pkg/internal/testutils/dynamic/clientset" + . "metacontroller/pkg/internal/testutils/dynamic/discovery" + "metacontroller/pkg/logging" "reflect" "testing" "github.com/google/go-cmp/cmp" - + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/json" + "k8s.io/client-go/dynamic/fake" + clientgotesting "k8s.io/client-go/testing" + "sigs.k8s.io/controller-runtime/pkg/log/zap" ) +type childUpdateOnDeleteStrategy struct{} + +func (m childUpdateOnDeleteStrategy) GetMethod(string, string) v1alpha1.ChildUpdateMethod { + return v1alpha1.ChildUpdateOnDelete +} + +type childUpdateInPlaceStrategy struct{} + +func (m childUpdateInPlaceStrategy) GetMethod(string, string) v1alpha1.ChildUpdateMethod { + return v1alpha1.ChildUpdateInPlace +} + +type childUpdateRecreateStrategy struct{} + +func (m childUpdateRecreateStrategy) GetMethod(string, string) v1alpha1.ChildUpdateMethod { + return v1alpha1.ChildUpdateRecreate +} + func TestRevertObjectMetaSystemFields(t *testing.T) { origJSON := `{ "metadata": { @@ -65,3 +110,274 @@ func TestRevertObjectMetaSystemFields(t *testing.T) { t.Fatalf("revertObjectMetaSystemFields() = %#v, want %#v", got, want) } } + +func TestManageChildren(t *testing.T) { + logging.InitLogging(&zap.Options{}) + type args struct { + dynClient func() *dynamicclientset.Clientset + updateStrategy ChildUpdateStrategy + parent *unstructured.Unstructured + observedChildren RelativeObjectMap + desiredChildren RelativeObjectMap + } + + unstructuredDefault := NewDefaultUnstructured() + unstructuredDefaultList := []*unstructured.Unstructured{unstructuredDefault} + simpleClientset := NewFakeNewSimpleClientsetWithResources(NewDefaultAPIResourceList()) + testResourceMap := NewFakeResourceMap(simpleClientset) + testRestConfig := NewDefaultRestConfig() + + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "no error on successful child delete", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateOnDeleteStrategy{}, + parent: unstructuredDefault, + observedChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + desiredChildren: nil, + }, + }, + { + name: "no error on child delete with not found api error", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + simpleDynClient.PrependReactor("delete", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewNotFound(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName) + }) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateOnDeleteStrategy{}, + parent: unstructuredDefault, + observedChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + desiredChildren: nil, + }, + }, + { + name: "no error on child delete during recreate with not found api error", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + simpleDynClient.PrependReactor("delete", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewNotFound(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName) + }) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateRecreateStrategy{}, + parent: unstructuredDefault, + observedChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + desiredChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + }, + }, + { + name: "error on child delete during recreate with unexpected api error", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + simpleDynClient.PrependReactor("delete", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewBadRequest("bad request") + }) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateRecreateStrategy{}, + parent: unstructuredDefault, + observedChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + desiredChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + }, + wantErr: true, + }, + { + name: "error on child delete with unexpected api error", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + simpleDynClient.PrependReactor("delete", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewBadRequest("bad request") + }) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateOnDeleteStrategy{}, + parent: unstructuredDefault, + observedChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + desiredChildren: nil, + }, + wantErr: true, + }, + { + name: "no error on successful child update", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateInPlaceStrategy{}, + parent: unstructuredDefault, + observedChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + desiredChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + }, + }, + { + name: "no error on child update with not found api error", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + simpleDynClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewNotFound(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName) + }) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateInPlaceStrategy{}, + parent: unstructuredDefault, + observedChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + desiredChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + }, + }, + { + name: "no error on child update with conflict api error", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + simpleDynClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewConflict(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName, nil) + }) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateInPlaceStrategy{}, + parent: unstructuredDefault, + observedChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + desiredChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + }, + }, + { + name: "error on child update with unexpected api error", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + simpleDynClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewBadRequest("bad request") + }) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateInPlaceStrategy{}, + parent: unstructuredDefault, + observedChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + desiredChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + }, + wantErr: true, + }, + { + name: "no error on child create with already exists api error", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + simpleDynClient.PrependReactor("create", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewAlreadyExists(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName) + }) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateInPlaceStrategy{}, + parent: unstructuredDefault, + observedChildren: nil, + desiredChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + }, + }, + { + name: "error on child create with unexpected api error", + args: args{ + dynClient: func() *dynamicclientset.Clientset { + simpleDynClient := fake.NewSimpleDynamicClient(scheme, NewDefaultUnstructured()) + simpleDynClient.PrependReactor("create", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewBadRequest("bad request") + }) + return NewClientset(testRestConfig, testResourceMap, simpleDynClient) + }, + updateStrategy: childUpdateOnDeleteStrategy{}, + parent: unstructuredDefault, + observedChildren: nil, + desiredChildren: MakeRelativeObjectMap( + unstructuredDefault, + unstructuredDefaultList, + ), + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ManageChildren(tt.args.dynClient(), tt.args.updateStrategy, tt.args.parent, tt.args.observedChildren, tt.args.desiredChildren); (err != nil) != tt.wantErr { + t.Errorf("ManageChildren() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/controller/composite/controller.go b/pkg/controller/composite/controller.go index 4e1f1214ff..78e64af94c 100644 --- a/pkg/controller/composite/controller.go +++ b/pkg/controller/composite/controller.go @@ -484,13 +484,14 @@ func (pc *parentController) sync(key string) error { pc.logger.V(4).Info("Sync", "object", klog.KRef(namespace, name)) parent, err := common.GetObject(pc.parentInformer, namespace, name) - if apierrors.IsNotFound(err) { - // Swallow the error since there's no point retrying if the parent is gone. - pc.logger.V(4).Info("Parent object has been deleted", "parent_kind", pc.parentResource.Kind, "object", klog.KRef(namespace, name)) - return nil - } if err != nil { - return err + if apierrors.IsNotFound(err) { + // Swallow the error since there's no point retrying if the parent is gone. + pc.logger.V(4).Info("Parent object has been deleted", "parent_kind", pc.parentResource.Kind, "object", klog.KRef(namespace, name)) + return nil + } else { + return err + } } err = pc.syncParentObject(parent) if err != nil { @@ -596,6 +597,15 @@ func (pc *parentController) syncParentObject(parent *unstructured.Unstructured) // Update parent status. // We'll want to make sure this happens after manageChildren once we support observedGeneration. if _, err := pc.updateParentStatus(parent, syncResult.Status); err != nil { + if apierrors.IsNotFound(err) { + // Swallow the error since there's no point retrying if the parent is gone. + pc.logger.V(4).Info("Parent object has been deleted", "parent_kind", pc.parentResource.Kind, "object", klog.KRef(parent.GetNamespace(), parent.GetName())) + return nil + } else if apierrors.IsConflict(err) { + // it is possible that the object was modified after this sync was started, ignore conflict since we will reconcile again + pc.logger.V(4).Info("Parent ignoring update due to outdated resourceVersion", "parent_kind", pc.parentResource.Kind, "object", klog.KRef(parent.GetNamespace(), parent.GetName())) + return nil + } return fmt.Errorf("can't update status for %v %v/%v: %w", pc.parentResource.Kind, parent.GetNamespace(), parent.GetName(), err) } diff --git a/pkg/controller/composite/controller_test.go b/pkg/controller/composite/controller_test.go new file mode 100644 index 0000000000..c35e047d50 --- /dev/null +++ b/pkg/controller/composite/controller_test.go @@ -0,0 +1,346 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 composite + +import ( + "fmt" + "metacontroller/pkg/apis/metacontroller/v1alpha1" + "metacontroller/pkg/client/generated/clientset/internalclientset" + mclisters "metacontroller/pkg/client/generated/lister/metacontroller/v1alpha1" + "metacontroller/pkg/controller/common" + "metacontroller/pkg/controller/common/customize" + "metacontroller/pkg/controller/common/finalizer" + dynamicclientset "metacontroller/pkg/dynamic/clientset" + dynamicdiscovery "metacontroller/pkg/dynamic/discovery" + dynamicinformer "metacontroller/pkg/dynamic/informer" + "metacontroller/pkg/hooks" + . "metacontroller/pkg/internal/testutils/common" + . "metacontroller/pkg/internal/testutils/dynamic/clientset" + . "metacontroller/pkg/internal/testutils/dynamic/discovery" + . "metacontroller/pkg/internal/testutils/hooks" + "metacontroller/pkg/logging" + "testing" + "time" + + "github.com/go-logr/logr" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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" + "k8s.io/client-go/dynamic/fake" + clientgotesting "k8s.io/client-go/testing" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func defaultCustomizeManager() *customize.Manager { + customizeManager, _ := customize.NewCustomizeManager( + "name", + func(obj interface{}) {}, + &NilCustomizableController{}, + &dynamicclientset.Clientset{}, + &dynamicinformer.SharedInformerFactory{}, + make(common.InformerMap), + make(common.GroupKindMap), + logging.Logger, + common.CompositeController, + ) + return customizeManager +} + +var defaultSyncResponse = &SyncHookResponse{ + Status: nil, + Children: nil, + ResyncAfterSeconds: 0, + Finalized: false, +} + +func newDefaultControllerClientsAndInformers(fakeDynamicClientFn func(client *fake.FakeDynamicClient), syncCache bool) (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, *dynamicinformer.ResourceInformer) { + gvrToListKind := map[schema.GroupVersionResource]string{ + {Group: TestGroup, Version: TestVersion, Resource: TestResource}: TestResourceList, + } + + simpleDynClient := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), gvrToListKind, NewDefaultUnstructured()) + fakeDynamicClientFn(simpleDynClient) + simpleClientset := NewFakeNewSimpleClientsetWithResources(NewDefaultAPIResourceList()) + resourceMap := NewFakeResourceMap(simpleClientset) + restConfig := NewDefaultRestConfig() + testClientset := NewClientset(restConfig, resourceMap, simpleDynClient) + parentResourceClient, _ := testClientset.Resource(TestAPIVersion, TestResource) + informerFactory := dynamicinformer.NewSharedInformerFactory(testClientset, 5*time.Minute) + resourceInformer, _ := informerFactory.Resource(TestAPIVersion, TestResource) + if syncCache && !cache.WaitForNamedCacheSync("controllerName", NewCh(), resourceInformer.Informer().HasSynced) { + panic("could not sync resource informer cache") + } + return simpleDynClient, resourceMap, testClientset, parentResourceClient, resourceInformer +} + +var defaultTestKey = fmt.Sprintf("%s/%s", TestNamespace, TestName) + +func newDefaultCompositeController() *v1alpha1.CompositeController { + generateSelector := true + return &v1alpha1.CompositeController{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{}, + Spec: v1alpha1.CompositeControllerSpec{ + GenerateSelector: &generateSelector, + Hooks: &v1alpha1.CompositeControllerHooks{ + Sync: &v1alpha1.Hook{ + Webhook: &v1alpha1.Webhook{ + URL: nil, + Timeout: nil, + Path: nil, + Service: nil, + }, + }, + Finalize: &v1alpha1.Hook{ + Webhook: &v1alpha1.Webhook{ + URL: nil, + Timeout: nil, + Path: nil, + Service: nil, + }, + }, + }, + }, + Status: v1alpha1.CompositeControllerStatus{}, + } +} + +func Test_parentController_sync(t *testing.T) { + logging.InitLogging(&zap.Options{}) + type fields struct { + cc *v1alpha1.CompositeController + parentResource *dynamicdiscovery.APIResource + mcClient internalclientset.Interface + revisionLister mclisters.ControllerRevisionLister + stopCh chan struct{} + doneCh chan struct{} + queue workqueue.RateLimitingInterface + updateStrategy updateStrategyMap + childInformers common.InformerMap + numWorkers int + eventRecorder record.EventRecorder + finalizer *finalizer.Manager + customize *customize.Manager + syncHook hooks.HookExecutor + finalizeHook hooks.HookExecutor + logger logr.Logger + } + type args struct { + key string + } + tests := []struct { + name string + clientsAndInformers func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, *dynamicinformer.ResourceInformer) + fields fields + args args + wantErr bool + }{ + { + name: "no error on successful sync", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, *dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("list", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + result := unstructured.UnstructuredList{ + Object: make(map[string]interface{}), + Items: []unstructured.Unstructured{ + *NewDefaultUnstructured(), + }, + } + return true, &result, nil + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true) + }, + fields: fields{ + cc: newDefaultCompositeController(), + parentResource: &DefaultApiResource, + mcClient: nil, + revisionLister: nil, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(defaultSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "no error on sync with not found api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, *dynamicinformer.ResourceInformer) { + return newDefaultControllerClientsAndInformers(NoOpFn, false) + }, + fields: fields{ + cc: newDefaultCompositeController(), + parentResource: &DefaultApiResource, + mcClient: nil, + revisionLister: nil, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(defaultSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "no error on update parent with not found api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, *dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewNotFound(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName) + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true) + }, + fields: fields{ + cc: newDefaultCompositeController(), + parentResource: &DefaultApiResource, + mcClient: nil, + revisionLister: nil, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(defaultSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "no error on update parent with conflict api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, *dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewConflict(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName, nil) + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true) + }, + fields: fields{ + cc: newDefaultCompositeController(), + parentResource: &DefaultApiResource, + mcClient: nil, + revisionLister: nil, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(defaultSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "error on update parent status with unexpected api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, *dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewBadRequest("bad request") + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true) + }, + fields: fields{ + cc: newDefaultCompositeController(), + parentResource: &DefaultApiResource, + mcClient: nil, + revisionLister: nil, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(defaultSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, resources, dynClient, parentClient, parentInformer := tt.clientsAndInformers() + pc := &parentController{ + cc: tt.fields.cc, + resources: resources, + parentResource: tt.fields.parentResource, + mcClient: tt.fields.mcClient, + dynClient: dynClient, + parentClient: parentClient, + parentInformer: parentInformer, + revisionLister: tt.fields.revisionLister, + stopCh: tt.fields.stopCh, + doneCh: tt.fields.doneCh, + queue: tt.fields.queue, + updateStrategy: tt.fields.updateStrategy, + childInformers: tt.fields.childInformers, + numWorkers: tt.fields.numWorkers, + eventRecorder: tt.fields.eventRecorder, + finalizer: tt.fields.finalizer, + customize: tt.fields.customize, + syncHook: tt.fields.syncHook, + finalizeHook: tt.fields.finalizeHook, + logger: tt.fields.logger, + } + if err := pc.sync(tt.args.key); (err != nil) != tt.wantErr { + t.Errorf("sync() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/controller/decorator/controller.go b/pkg/controller/decorator/controller.go index 9781dbd1d0..1ec83f8252 100644 --- a/pkg/controller/decorator/controller.go +++ b/pkg/controller/decorator/controller.go @@ -477,13 +477,14 @@ func (c *decoratorController) sync(key string) error { return fmt.Errorf("no informer for resource %q in apiVersion %q", resource.Name, apiVersion) } parent, err := common.GetObject(informer, namespace, name) - if apierrors.IsNotFound(err) { - // Swallow the error since there's no point retrying if the parent is gone. - c.logger.V(4).Info("Parent object has been deleted", "kind", kind, "object", klog.KRef(namespace, name)) - return nil - } if err != nil { - return err + if apierrors.IsNotFound(err) { + // Swallow the error since there's no point retrying if the parent is gone. + c.logger.V(4).Info("Parent object has been deleted", "kind", kind, "object", klog.KRef(namespace, name)) + return nil + } else { + return err + } } err = c.syncParentObject(parent) if err != nil { @@ -591,7 +592,18 @@ func (c *decoratorController) syncParentObject(parent *unstructured.Unstructured // The regular Update below will ignore changes to .status so we do it separately. result, err := parentClient.Namespace(parent.GetNamespace()).UpdateStatus(context.TODO(), updatedParent, metav1.UpdateOptions{}) if err != nil { - return fmt.Errorf("can't update status: %w", err) + switch { + case apierrors.IsNotFound(err): + // Swallow the error since there's no point retrying if the child is gone. + c.logger.V(4).Info("DecoratorController Failed to sync status, parent object has been deleted", "controller", c.dc, "parent", parent) + return nil + case apierrors.IsConflict(err): + // it is possible that the object was modified after this sync was started, ignore conflict since we will reconcile again + c.logger.V(4).Info("DecoratorController ignoring update status due to outdated resourceVersion", "controller", c.dc, "parent", parent) + return nil + default: + return fmt.Errorf("can't update status: %w", err) + } } // The Update below needs to use the latest ResourceVersion. updatedParent.SetResourceVersion(result.GetResourceVersion()) @@ -604,6 +616,15 @@ func (c *decoratorController) syncParentObject(parent *unstructured.Unstructured c.logger.V(4).Info("DecoratorController updating", "controller", c.dc, "parent", parent) _, err = parentClient.Namespace(parent.GetNamespace()).Update(context.TODO(), updatedParent, metav1.UpdateOptions{}) if err != nil { + if apierrors.IsNotFound(err) { + // Swallow the error since there's no point retrying if the parent is gone. + c.logger.V(4).Info("DecoratorController Failed to sync, parent object has been deleted", "controller", c.dc, "parent", parent) + return nil + } else if apierrors.IsConflict(err) { + // it is possible that the object was modified after this sync was started, ignore conflict since we will reconcile again + c.logger.V(4).Info("DecoratorController ignoring update due to outdated resourceVersion", "controller", c.dc, "parent", parent) + return nil + } return fmt.Errorf("can't update %v %v/%v: %w", parent.GetKind(), parent.GetNamespace(), parent.GetName(), err) } } diff --git a/pkg/controller/decorator/controller_test.go b/pkg/controller/decorator/controller_test.go new file mode 100644 index 0000000000..f6221a24a3 --- /dev/null +++ b/pkg/controller/decorator/controller_test.go @@ -0,0 +1,468 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 decorator + +import ( + "fmt" + "metacontroller/pkg/apis/metacontroller/v1alpha1" + "metacontroller/pkg/controller/common" + "metacontroller/pkg/controller/common/customize" + "metacontroller/pkg/controller/common/finalizer" + dynamicclientset "metacontroller/pkg/dynamic/clientset" + dynamicdiscovery "metacontroller/pkg/dynamic/discovery" + dynamicinformer "metacontroller/pkg/dynamic/informer" + "metacontroller/pkg/hooks" + . "metacontroller/pkg/internal/testutils/common" + . "metacontroller/pkg/internal/testutils/dynamic/clientset" + . "metacontroller/pkg/internal/testutils/dynamic/discovery" + . "metacontroller/pkg/internal/testutils/hooks" + "metacontroller/pkg/logging" + "testing" + "time" + + "github.com/go-logr/logr" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + clientgotesting "k8s.io/client-go/testing" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +func defaultCustomizeManager() *customize.Manager { + customizeManager, _ := customize.NewCustomizeManager( + "name", + func(obj interface{}) {}, + &NilCustomizableController{}, + &dynamicclientset.Clientset{}, + &dynamicinformer.SharedInformerFactory{}, + make(common.InformerMap), + make(common.GroupKindMap), + logging.Logger, + common.DecoratorController, + ) + return customizeManager +} + +var defaultSyncResponse = &SyncHookResponse{ + Status: nil, + ResyncAfterSeconds: 0, + Finalized: false, +} + +var changedStatusSyncResponse = &SyncHookResponse{ + ResyncAfterSeconds: 0, + Finalized: false, + Status: map[string]interface{}{ + "changed": "true", + }, +} + +func newDefaultControllerClientsAndInformers(fakeDynamicClientFn func(client *fake.FakeDynamicClient), syncCache bool, hasStatusSubresource bool) (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) { + gvrToListKind := map[schema.GroupVersionResource]string{ + {Group: TestGroup, Version: TestVersion, Resource: TestResource}: TestResourceList, + } + + simpleDynClient := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), gvrToListKind, newUnstructuredWithSelectors()) + fakeDynamicClientFn(simpleDynClient) + + var apiResourceList []*metav1.APIResourceList + if hasStatusSubresource { + apiResourceList = NewDefaultStatusAPIResourceList() + } else { + apiResourceList = NewDefaultAPIResourceList() + } + + simpleClientset := NewFakeNewSimpleClientsetWithResources(apiResourceList) + resourceMap := NewFakeResourceMap(simpleClientset) + restConfig := NewDefaultRestConfig() + testClientset := NewClientset(restConfig, resourceMap, simpleDynClient) + parentResourceClient, _ := testClientset.Resource(TestAPIVersion, TestResource) + informerFactory := dynamicinformer.NewSharedInformerFactory(testClientset, 5*time.Minute) + resourceInformer, _ := informerFactory.Resource(TestAPIVersion, TestResource) + if syncCache && !cache.WaitForNamedCacheSync("controllerName", NewCh(), resourceInformer.Informer().HasSynced) { + panic("could not sync resource informer cache") + } + resourceInformers := map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer{ + {Group: TestGroup, Version: TestVersion, Resource: TestResource}: resourceInformer, + } + return simpleDynClient, resourceMap, testClientset, parentResourceClient, resourceInformers +} + +var defaultTestKey = fmt.Sprintf("%s:%s:%s:%s", TestAPIVersion, TestKind, TestNamespace, TestName) + +func newDefaultDecoratorController() *v1alpha1.DecoratorController { + return &v1alpha1.DecoratorController{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{}, + Spec: v1alpha1.DecoratorControllerSpec{ + Hooks: &v1alpha1.DecoratorControllerHooks{ + Sync: &v1alpha1.Hook{ + Webhook: &v1alpha1.Webhook{ + URL: nil, + Timeout: nil, + Path: nil, + Service: nil, + }, + }, + Finalize: &v1alpha1.Hook{ + Webhook: &v1alpha1.Webhook{ + URL: nil, + Timeout: nil, + Path: nil, + Service: nil, + }, + }, + }, + }, + Status: v1alpha1.DecoratorControllerStatus{}, + } +} + +var defaultGroupKindMap = map[schema.GroupKind]*dynamicdiscovery.APIResource{ + {Group: TestGroup, Kind: TestKind}: &DefaultApiResource, +} + +var defaultSelectorKey = fmt.Sprintf("%s.%s", TestKind, TestGroup) +var defaultLabels = map[string]string{"key": "val"} +var defaultSelector = map[string]labels.Selector{ + defaultSelectorKey: labels.SelectorFromSet(defaultLabels), +} +var defaultParentSelector = &decoratorSelector{ + labelSelectors: defaultSelector, + annotationSelectors: defaultSelector, +} + +func newUnstructuredWithSelectors() *unstructured.Unstructured { + defaultUnstructured := NewDefaultUnstructured() + defaultUnstructured.SetLabels(defaultLabels) + defaultUnstructured.SetAnnotations(defaultLabels) + return defaultUnstructured +} + +func Test_decoratorController_sync(t *testing.T) { + logging.InitLogging(&zap.Options{}) + type fields struct { + dc *v1alpha1.DecoratorController + parentKinds common.GroupKindMap + parentSelector *decoratorSelector + stopCh chan struct{} + doneCh chan struct{} + queue workqueue.RateLimitingInterface + updateStrategy updateStrategyMap + childInformers common.InformerMap + numWorkers int + eventRecorder record.EventRecorder + finalizer *finalizer.Manager + customize *customize.Manager + syncHook hooks.HookExecutor + finalizeHook hooks.HookExecutor + logger logr.Logger + } + type args struct { + key string + } + tests := []struct { + name string + clientsAndInformers func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) + fields fields + args args + wantErr bool + }{ + { + name: "no error on successful sync", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("list", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + result := unstructured.UnstructuredList{ + Object: make(map[string]interface{}), + Items: []unstructured.Unstructured{ + *newUnstructuredWithSelectors(), + }, + } + return true, &result, nil + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true, false) + }, + fields: fields{ + dc: newDefaultDecoratorController(), + parentKinds: defaultGroupKindMap, + parentSelector: defaultParentSelector, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(defaultSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "no error on sync with not found api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) { + return newDefaultControllerClientsAndInformers(NoOpFn, false, false) + }, + fields: fields{ + dc: newDefaultDecoratorController(), + parentKinds: defaultGroupKindMap, + parentSelector: defaultParentSelector, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(defaultSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "no error on update parent with not found api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewNotFound(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName) + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true, false) + }, + fields: fields{ + dc: newDefaultDecoratorController(), + parentKinds: defaultGroupKindMap, + parentSelector: defaultParentSelector, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(changedStatusSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "no error on update status parent with not found api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewNotFound(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName) + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true, true) + }, + fields: fields{ + dc: newDefaultDecoratorController(), + parentKinds: defaultGroupKindMap, + parentSelector: defaultParentSelector, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(changedStatusSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "no error on update parent with conflict api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewConflict(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName, nil) + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true, false) + }, + fields: fields{ + dc: newDefaultDecoratorController(), + parentKinds: defaultGroupKindMap, + parentSelector: defaultParentSelector, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(changedStatusSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "no error on update status parent with conflict api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewConflict(schema.GroupResource{ + Group: TestGroup, + Resource: TestResource, + }, TestName, nil) + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true, true) + }, + fields: fields{ + dc: newDefaultDecoratorController(), + parentKinds: defaultGroupKindMap, + parentSelector: defaultParentSelector, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(changedStatusSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + }, + { + name: "error on update parent with unexpected api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewBadRequest("bad request") + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true, false) + }, + fields: fields{ + dc: newDefaultDecoratorController(), + parentKinds: defaultGroupKindMap, + parentSelector: defaultParentSelector, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(changedStatusSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + wantErr: true, + }, + { + name: "error on update status parent with unexpected api error", + clientsAndInformers: func() (*fake.FakeDynamicClient, *dynamicdiscovery.ResourceMap, *dynamicclientset.Clientset, *dynamicclientset.ResourceClient, map[schema.GroupVersionResource]*dynamicinformer.ResourceInformer) { + fakeDynamicClientFn := func(fakeDynamicClient *fake.FakeDynamicClient) { + fakeDynamicClient.PrependReactor("update", "*", func(action clientgotesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, apierrors.NewBadRequest("bad request") + }) + } + return newDefaultControllerClientsAndInformers(fakeDynamicClientFn, true, true) + }, + fields: fields{ + dc: newDefaultDecoratorController(), + parentKinds: defaultGroupKindMap, + parentSelector: defaultParentSelector, + stopCh: NewCh(), + doneCh: NewCh(), + queue: NewDefaultWorkQueue(), + updateStrategy: nil, + childInformers: nil, + numWorkers: 1, + eventRecorder: NewFakeRecorder(), + finalizer: DefaultFinalizerManager, + customize: defaultCustomizeManager(), + syncHook: NewHookExecutorStub(changedStatusSyncResponse), + finalizeHook: NewHookExecutorStub(defaultSyncResponse), + logger: logging.Logger, + }, + args: args{key: defaultTestKey}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, resources, dynClient, _, parentInformers := tt.clientsAndInformers() + c := &decoratorController{ + dc: tt.fields.dc, + resources: resources, + parentKinds: tt.fields.parentKinds, + parentSelector: tt.fields.parentSelector, + dynClient: dynClient, + stopCh: tt.fields.stopCh, + doneCh: tt.fields.doneCh, + queue: tt.fields.queue, + updateStrategy: tt.fields.updateStrategy, + parentInformers: parentInformers, + childInformers: tt.fields.childInformers, + numWorkers: tt.fields.numWorkers, + eventRecorder: tt.fields.eventRecorder, + finalizer: tt.fields.finalizer, + customize: tt.fields.customize, + syncHook: tt.fields.syncHook, + finalizeHook: tt.fields.finalizeHook, + logger: tt.fields.logger, + } + if err := c.sync(tt.args.key); (err != nil) != tt.wantErr { + t.Errorf("sync() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/dynamic/clientset/clientset.go b/pkg/dynamic/clientset/clientset.go index 6ae6ea3d11..76fca57f86 100644 --- a/pkg/dynamic/clientset/clientset.go +++ b/pkg/dynamic/clientset/clientset.go @@ -37,16 +37,20 @@ type Clientset struct { dc dynamic.Interface } +func NewClientset(config *rest.Config, resources *dynamicdiscovery.ResourceMap, dc dynamic.Interface) *Clientset { + return &Clientset{ + config: *config, + resources: resources, + dc: dc, + } +} + func New(config *rest.Config, resources *dynamicdiscovery.ResourceMap) (*Clientset, error) { dc, err := dynamic.NewForConfig(config) if err != nil { return nil, fmt.Errorf("can't create dynamic client when creating clientset: %w", err) } - return &Clientset{ - config: *config, - resources: resources, - dc: dc, - }, nil + return NewClientset(config, resources, dc), nil } func (cs *Clientset) HasSynced() bool { diff --git a/pkg/internal/testutils/common/metav1.go b/pkg/internal/testutils/common/metav1.go new file mode 100644 index 0000000000..3520de339f --- /dev/null +++ b/pkg/internal/testutils/common/metav1.go @@ -0,0 +1,74 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 common + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func NewDefaultAPIResource() metav1.APIResource { + return metav1.APIResource{ + Name: TestResource, + Namespaced: true, + Group: TestGroup, + Version: TestVersion, + Kind: TestKind, + } +} + +func NewDefaultStatusAPIResource() metav1.APIResource { + return metav1.APIResource{ + Name: TestResourceStatus, + Namespaced: true, + Group: TestGroup, + Version: TestVersion, + Kind: TestKind, + } +} + +func NewDefaultAPIResourceList() []*metav1.APIResourceList { + return []*metav1.APIResourceList{ + { + TypeMeta: metav1.TypeMeta{ + Kind: TestKind, + APIVersion: TestAPIVersion, + }, + GroupVersion: fmt.Sprintf("%s/%s", TestGroup, TestVersion), + APIResources: []metav1.APIResource{ + NewDefaultAPIResource(), + }, + }, + } +} + +func NewDefaultStatusAPIResourceList() []*metav1.APIResourceList { + return []*metav1.APIResourceList{ + { + TypeMeta: metav1.TypeMeta{ + Kind: TestKind, + APIVersion: TestAPIVersion, + }, + GroupVersion: fmt.Sprintf("%s/%s", TestGroup, TestVersion), + APIResources: []metav1.APIResource{ + NewDefaultAPIResource(), + NewDefaultStatusAPIResource(), + }, + }, + } +} diff --git a/pkg/internal/testutils/common/rest.go b/pkg/internal/testutils/common/rest.go new file mode 100644 index 0000000000..d3aa86b373 --- /dev/null +++ b/pkg/internal/testutils/common/rest.go @@ -0,0 +1,33 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 common + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" +) + +func NewDefaultRestConfig() *rest.Config { + return &rest.Config{ + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{ + Group: TestGroup, + Version: TestVersion, + }, + }, + } +} diff --git a/pkg/internal/testutils/common/unstructured.go b/pkg/internal/testutils/common/unstructured.go new file mode 100644 index 0000000000..78f812d8fa --- /dev/null +++ b/pkg/internal/testutils/common/unstructured.go @@ -0,0 +1,48 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 common + +import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + +const ( + TestGroup = "testgroup" + TestVersion = "testversion" + TestResource = "testkinds" + TestResourceStatus = "testkinds/status" + TestResourceList = "TestkindsList" + TestNamespace = "testns" + TestName = "testname" + TestKind = "TestKind" + TestAPIVersion = "testgroup/testversion" +) + +func NewDefaultUnstructured() *unstructured.Unstructured { + return NewUnstructured(TestAPIVersion, TestKind, TestNamespace, TestName) +} + +func NewUnstructured(apiVersion, kind, namespace, name string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "namespace": namespace, + "name": name, + }, + }, + } +} diff --git a/pkg/internal/testutils/common/util.go b/pkg/internal/testutils/common/util.go new file mode 100644 index 0000000000..90b0ee8027 --- /dev/null +++ b/pkg/internal/testutils/common/util.go @@ -0,0 +1,47 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 common + +import ( + "metacontroller/pkg/controller/common/finalizer" + dynamicdiscovery "metacontroller/pkg/dynamic/discovery" + + "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" +) + +func NewCh() chan struct{} { + return make(chan struct{}, 1) +} + +var NoOpFn = func(fakeDynamicClient *fake.FakeDynamicClient) {} + +var DefaultFinalizerManager = finalizer.NewManager("testFinalizerManager", false) + +var DefaultApiResource = dynamicdiscovery.APIResource{ + APIResource: NewDefaultAPIResource(), + APIVersion: TestAPIVersion, +} + +func NewFakeRecorder() *record.FakeRecorder { + return record.NewFakeRecorder(1) +} + +func NewDefaultWorkQueue() workqueue.RateLimitingInterface { + return workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "testQueue") +} diff --git a/pkg/internal/testutils/dynamic/clientset/clientset.go b/pkg/internal/testutils/dynamic/clientset/clientset.go new file mode 100644 index 0000000000..5da902132e --- /dev/null +++ b/pkg/internal/testutils/dynamic/clientset/clientset.go @@ -0,0 +1,42 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 clientset + +import ( + dynamicclientset "metacontroller/pkg/dynamic/clientset" + dynamicdiscovery "metacontroller/pkg/dynamic/discovery" + . "metacontroller/pkg/internal/testutils/common" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + fakeclientset "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" +) + +func NewFakeNewSimpleClientsetWithResources(apiResourceList []*metav1.APIResourceList) *fakeclientset.Clientset { + simpleClientset := fakeclientset.NewSimpleClientset(NewDefaultUnstructured()) + simpleClientset.Resources = apiResourceList + return simpleClientset +} + +func NewClientset(restConfig *rest.Config, resourceMap *dynamicdiscovery.ResourceMap, dc dynamic.Interface) *dynamicclientset.Clientset { + return dynamicclientset.NewClientset( + restConfig, + resourceMap, + dc, + ) +} diff --git a/pkg/internal/testutils/dynamic/discovery/discovery.go b/pkg/internal/testutils/dynamic/discovery/discovery.go new file mode 100644 index 0000000000..be4a4fe2b3 --- /dev/null +++ b/pkg/internal/testutils/dynamic/discovery/discovery.go @@ -0,0 +1,35 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 discovery + +import ( + dynamicdiscovery "metacontroller/pkg/dynamic/discovery" + "time" + + fakediscovery "k8s.io/client-go/discovery/fake" + fakeclientset "k8s.io/client-go/kubernetes/fake" +) + +func NewFakeResourceMap(simpleClientset *fakeclientset.Clientset) *dynamicdiscovery.ResourceMap { + fakeDiscovery := simpleClientset.Discovery().(*fakediscovery.FakeDiscovery) + resourceMap := dynamicdiscovery.NewResourceMap(fakeDiscovery) + resourceMap.Start(1 * time.Minute) + for ok := false; !ok; ok = resourceMap.HasSynced() { + time.Sleep(1 * time.Second) + } + return resourceMap +} diff --git a/pkg/internal/testutils/hooks.go b/pkg/internal/testutils/hooks.go deleted file mode 100644 index 6bf1c55cd6..0000000000 --- a/pkg/internal/testutils/hooks.go +++ /dev/null @@ -1,45 +0,0 @@ -package testutils - -import ( - "fmt" - "metacontroller/pkg/hooks" - "reflect" -) - -// NewHookExecutorStub creates new HookExecutorStub which returns given response -func NewHookExecutorStub(response interface{}) hooks.HookExecutor { - return &hookExecutorStub{ - enabled: true, - response: response, - } -} - -// HookExecutorStub is HookExecutor stub to return any given response -type hookExecutorStub struct { - enabled bool - response interface{} -} - -func (h *hookExecutorStub) IsEnabled() bool { - return true -} - -func (h *hookExecutorStub) Execute(request interface{}, response interface{}) error { - val := reflect.ValueOf(response) - if val.Kind() != reflect.Ptr { - return fmt.Errorf(`panic("not a pointer")`) - } - - val = val.Elem() - - newVal := reflect.Indirect(reflect.ValueOf(h.response)) - - if !val.Type().AssignableTo(newVal.Type()) { - return fmt.Errorf(`panic("mismatched types")`) - } - - val.Set(newVal) - return nil -} - -func (h hookExecutorStub) Close() {} diff --git a/pkg/internal/testutils/hooks/hooks.go b/pkg/internal/testutils/hooks/hooks.go new file mode 100644 index 0000000000..9fa9a53c4d --- /dev/null +++ b/pkg/internal/testutils/hooks/hooks.go @@ -0,0 +1,81 @@ +/* +Copyright 2021 Metacontroller 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 + + https://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 hooks + +import ( + "fmt" + "metacontroller/pkg/apis/metacontroller/v1alpha1" + "metacontroller/pkg/hooks" + "reflect" +) + +// NewHookExecutorStub creates new HookExecutorStub which returns given response +func NewHookExecutorStub(response interface{}) hooks.HookExecutor { + return &hookExecutorStub{ + enabled: true, + response: response, + } +} + +// HookExecutorStub is HookExecutor stub to return any given response +type hookExecutorStub struct { + enabled bool + response interface{} +} + +func (h *hookExecutorStub) IsEnabled() bool { + return true +} + +func (h *hookExecutorStub) Execute(request interface{}, response interface{}) error { + val := reflect.ValueOf(response) + if val.Kind() != reflect.Ptr { + return fmt.Errorf(`panic("not a pointer")`) + } + + val = val.Elem() + + newVal := reflect.Indirect(reflect.ValueOf(h.response)) + + if !val.Type().AssignableTo(newVal.Type()) { + return fmt.Errorf(`panic("mismatched types")`) + } + + val.Set(newVal) + return nil +} + +func (h hookExecutorStub) Close() {} + +type NilCustomizableController struct { +} + +func (cc *NilCustomizableController) GetCustomizeHook() *v1alpha1.Hook { + return nil +} + +type FakeCustomizableController struct { +} + +func (cc *FakeCustomizableController) GetCustomizeHook() *v1alpha1.Hook { + url := "fake" + return &v1alpha1.Hook{ + Webhook: &v1alpha1.Webhook{ + URL: &url, + }, + } +}