From f0bfe5b16e629a27aa0ca346ce8376ec3660f9ec Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Fri, 4 Aug 2023 17:00:11 -0400 Subject: [PATCH] Decompose reconcilers.go This is an organizational refactor and includes no new functionality or business logic changes. Signed-off-by: Scott Andrews --- reconcilers/aggregate.go | 267 + reconcilers/aggregate_test.go | 503 ++ reconcilers/cast.go | 151 + reconcilers/cast_test.go | 178 + reconcilers/child.go | 378 ++ reconcilers/child_test.go | 2038 ++++++++ reconcilers/config.go | 180 + reconcilers/config_test.go | 318 ++ reconcilers/enqueuer.go | 37 - reconcilers/finalizer.go | 165 + reconcilers/finalizer_test.go | 193 + reconcilers/patch.go | 59 - reconcilers/reconcilers.go | 1839 +------ reconcilers/reconcilers_test.go | 4419 ----------------- reconcilers/resource.go | 344 ++ reconcilers/resource_test.go | 798 +++ reconcilers/resourcemanager.go | 282 ++ ...{patch_test.go => resourcemanager_test.go} | 0 reconcilers/sequence.go | 55 + reconcilers/sequence_test.go | 287 ++ reconcilers/sync.go | 156 + reconcilers/sync_test.go | 273 + ...lers_validate_test.go => validate_test.go} | 0 23 files changed, 6629 insertions(+), 6291 deletions(-) create mode 100644 reconcilers/aggregate.go create mode 100644 reconcilers/aggregate_test.go create mode 100644 reconcilers/cast.go create mode 100644 reconcilers/cast_test.go create mode 100644 reconcilers/child.go create mode 100644 reconcilers/child_test.go create mode 100644 reconcilers/config.go create mode 100644 reconcilers/config_test.go delete mode 100644 reconcilers/enqueuer.go create mode 100644 reconcilers/finalizer.go create mode 100644 reconcilers/finalizer_test.go delete mode 100644 reconcilers/patch.go delete mode 100644 reconcilers/reconcilers_test.go create mode 100644 reconcilers/resource.go create mode 100644 reconcilers/resource_test.go create mode 100644 reconcilers/resourcemanager.go rename reconcilers/{patch_test.go => resourcemanager_test.go} (100%) create mode 100644 reconcilers/sequence.go create mode 100644 reconcilers/sequence_test.go create mode 100644 reconcilers/sync.go create mode 100644 reconcilers/sync_test.go rename reconcilers/{reconcilers_validate_test.go => validate_test.go} (100%) diff --git a/reconcilers/aggregate.go b/reconcilers/aggregate.go new file mode 100644 index 0000000..a0a299f --- /dev/null +++ b/reconcilers/aggregate.go @@ -0,0 +1,267 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + + "github.com/go-logr/logr" + apierrs "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/vmware-labs/reconciler-runtime/internal" + "github.com/vmware-labs/reconciler-runtime/tracker" +) + +var ( + _ reconcile.Reconciler = (*AggregateReconciler[client.Object])(nil) +) + +// AggregateReconciler is a controller-runtime reconciler that reconciles a specific resource. The +// Type resource is fetched for the reconciler +// request and passed in turn to each SubReconciler. Finally, the reconciled +// resource's status is compared with the original status, updating the API +// server if needed. +type AggregateReconciler[Type client.Object] struct { + // Name used to identify this reconciler. Defaults to `{Type}ResourceReconciler`. Ideally + // unique, but not required to be so. + // + // +optional + Name string + + // Setup performs initialization on the manager and builder this reconciler + // will run with. It's common to setup field indexes and watch resources. + // + // +optional + Setup func(ctx context.Context, mgr Manager, bldr *Builder) error + + // Type of resource to reconcile. Required when the generic type is not a + // struct, or is unstructured. + // + // +optional + Type Type + + // Request of resource to reconcile. Only the specific resource matching the namespace and name + // is reconciled. The namespace may be empty for cluster scoped resources. + Request Request + + // Reconciler is called for each reconciler request with the resource being reconciled. + // Typically, Reconciler is a Sequence of multiple SubReconcilers. + // + // When HaltSubReconcilers is returned as an error, execution continues as if no error was + // returned. + // + // +optional + Reconciler SubReconciler[Type] + + // DesiredResource returns the desired resource to create/update, or nil if + // the resource should not exist. + // + // +optional + DesiredResource func(ctx context.Context, resource Type) (Type, error) + + // HarmonizeImmutableFields allows fields that are immutable on the current + // object to be copied to the desired object in order to avoid creating + // updates which are guaranteed to fail. + // + // +optional + HarmonizeImmutableFields func(current, desired Type) + + // MergeBeforeUpdate copies desired fields on to the current object before + // calling update. Typically fields to copy are the Spec, Labels and + // Annotations. + MergeBeforeUpdate func(current, desired Type) + + // Sanitize is called with an object before logging the value. Any value may + // be returned. A meaningful subset of the resource is typically returned, + // like the Spec. + // + // +optional + Sanitize func(resource Type) interface{} + + Config Config + + // stamp manages the lifecycle of the aggregated resource. + stamp *ResourceManager[Type] + lazyInit sync.Once +} + +func (r *AggregateReconciler[T]) init() { + r.lazyInit.Do(func() { + if internal.IsNil(r.Type) { + var nilT T + r.Type = newEmpty(nilT).(T) + } + if r.Name == "" { + r.Name = fmt.Sprintf("%sAggregateReconciler", typeName(r.Type)) + } + if r.Reconciler == nil { + r.Reconciler = Sequence[T]{} + } + if r.DesiredResource == nil { + r.DesiredResource = func(ctx context.Context, resource T) (T, error) { + return resource, nil + } + } + + r.stamp = &ResourceManager[T]{ + Name: r.Name, + Type: r.Type, + + HarmonizeImmutableFields: r.HarmonizeImmutableFields, + MergeBeforeUpdate: r.MergeBeforeUpdate, + Sanitize: r.Sanitize, + } + }) +} + +func (r *AggregateReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + _, err := r.SetupWithManagerYieldingController(ctx, mgr) + return err +} + +func (r *AggregateReconciler[T]) SetupWithManagerYieldingController(ctx context.Context, mgr ctrl.Manager) (controller.Controller, error) { + r.init() + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name). + WithValues( + "resourceType", gvk(r.Type, r.Config.Scheme()), + "request", r.Request, + ) + ctx = logr.NewContext(ctx, log) + + ctx = StashConfig(ctx, r.Config) + ctx = StashOriginalConfig(ctx, r.Config) + ctx = StashResourceType(ctx, r.Type) + ctx = StashOriginalResourceType(ctx, r.Type) + + if err := r.validate(ctx); err != nil { + return nil, err + } + + bldr := ctrl.NewControllerManagedBy(mgr).For(r.Type) + if r.Setup != nil { + if err := r.Setup(ctx, mgr, bldr); err != nil { + return nil, err + } + } + if err := r.Reconciler.SetupWithManager(ctx, mgr, bldr); err != nil { + return nil, err + } + if err := r.stamp.Setup(ctx); err != nil { + return nil, err + } + return bldr.Build(r) +} + +func (r *AggregateReconciler[T]) validate(ctx context.Context) error { + // validate Request value + if r.Request.Name == "" { + return fmt.Errorf("AggregateReconciler %q must define Request", r.Name) + } + + // validate Reconciler value + if r.Reconciler == nil && r.DesiredResource == nil { + return fmt.Errorf("AggregateReconciler %q must define Reconciler and/or DesiredResource", r.Name) + } + + return nil +} + +func (r *AggregateReconciler[T]) Reconcile(ctx context.Context, req Request) (Result, error) { + r.init() + + if req.Namespace != r.Request.Namespace || req.Name != r.Request.Name { + // ignore other requests + return Result{}, nil + } + + ctx = WithStash(ctx) + + c := r.Config + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name). + WithValues("resourceType", gvk(r.Type, c.Scheme())) + ctx = logr.NewContext(ctx, log) + + ctx = StashRequest(ctx, req) + ctx = StashConfig(ctx, c) + ctx = StashOriginalConfig(ctx, r.Config) + ctx = StashOriginalResourceType(ctx, r.Type) + ctx = StashResourceType(ctx, r.Type) + + resource := r.Type.DeepCopyObject().(T) + if err := c.Get(ctx, req.NamespacedName, resource); err != nil { + if apierrs.IsNotFound(err) { + // not found is ok + resource.SetNamespace(r.Request.Namespace) + resource.SetName(r.Request.Name) + } else { + log.Error(err, "unable to fetch resource") + return Result{}, err + } + } + + if resource.GetDeletionTimestamp() != nil { + // resource is being deleted, nothing to do + return Result{}, nil + } + + result, err := r.Reconciler.Reconcile(ctx, resource) + if err != nil && !errors.Is(err, HaltSubReconcilers) { + return result, err + } + + // hack, ignore track requests from the child reconciler, we have it covered + ctx = StashConfig(ctx, Config{ + Client: c.Client, + APIReader: c.APIReader, + Recorder: c.Recorder, + Tracker: tracker.New(c.Scheme(), 0), + }) + desired, err := r.desiredResource(ctx, resource) + if err != nil { + return Result{}, err + } + _, err = r.stamp.Manage(ctx, resource, resource, desired) + if err != nil { + return Result{}, err + } + return result, nil +} + +func (r *AggregateReconciler[T]) desiredResource(ctx context.Context, resource T) (T, error) { + var nilT T + + if resource.GetDeletionTimestamp() != nil { + // the reconciled resource is pending deletion, cleanup the child resource + return nilT, nil + } + + fn := reflect.ValueOf(r.DesiredResource) + out := fn.Call([]reflect.Value{ + reflect.ValueOf(ctx), + reflect.ValueOf(resource.DeepCopyObject()), + }) + var obj T + if !out[0].IsNil() { + obj = out[0].Interface().(T) + } + var err error + if !out[1].IsNil() { + err = out[1].Interface().(error) + } + return obj, err +} diff --git a/reconcilers/aggregate_test.go b/reconcilers/aggregate_test.go new file mode 100644 index 0000000..9bfcc9b --- /dev/null +++ b/reconcilers/aggregate_test.go @@ -0,0 +1,503 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers_test + +import ( + "context" + "fmt" + "testing" + "time" + + diecorev1 "dies.dev/apis/core/v1" + diemetav1 "dies.dev/apis/meta/v1" + "github.com/vmware-labs/reconciler-runtime/internal/resources" + "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtesting "github.com/vmware-labs/reconciler-runtime/testing" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func TestAggregateReconciler(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testFinalizer := "test.finalizer" + request := reconcilers.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, + } + + now := metav1.NewTime(time.Now().Truncate(time.Second)) + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + _ = clientgoscheme.AddToScheme(scheme) + + configMapCreate := diecorev1.ConfigMapBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }) + configMapGiven := configMapCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + }) + + defaultAggregateReconciler := func(c reconcilers.Config) *reconcilers.AggregateReconciler[*corev1.ConfigMap] { + return &reconcilers.AggregateReconciler[*corev1.ConfigMap]{ + Request: request, + + DesiredResource: func(ctx context.Context, resource *corev1.ConfigMap) (*corev1.ConfigMap, error) { + resource.Data = map[string]string{ + "foo": "bar", + } + return resource, nil + }, + MergeBeforeUpdate: func(current, desired *corev1.ConfigMap) { + current.Data = desired.Data + }, + + Config: c, + } + } + + rts := rtesting.ReconcilerTests{ + "resource is in sync": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven. + AddData("foo", "bar"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + return defaultAggregateReconciler(c) + }, + }, + }, + "ignore other resources": { + Request: reconcilers.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: "not-it"}, + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + return defaultAggregateReconciler(c) + }, + }, + }, + "ignore terminating resources": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + return defaultAggregateReconciler(c) + }, + }, + }, + "create resource": { + Request: request, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + return defaultAggregateReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate. + AddData("foo", "bar"), + }, + }, + "update resource": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + return defaultAggregateReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Updated", + `Updated ConfigMap %q`, testName), + }, + ExpectUpdates: []client.Object{ + configMapGiven. + AddData("foo", "bar"), + }, + }, + "delete resource": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.DesiredResource = func(ctx context.Context, resource *corev1.ConfigMap) (*corev1.ConfigMap, error) { + return nil, nil + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + }, + "preserve immutable fields": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven. + AddData("foo", "bar"). + AddData("immutable", "field"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.HarmonizeImmutableFields = func(current, desired *corev1.ConfigMap) { + desired.Data["immutable"] = current.Data["immutable"] + } + return r + }, + }, + }, + "sanitize resource before logging": { + Request: request, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Sanitize = func(child *corev1.ConfigMap) interface{} { + return child.Name + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate. + AddData("foo", "bar"), + }, + }, + "sanitize is mutation safe": { + Request: request, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Sanitize = func(child *corev1.ConfigMap) interface{} { + child.Data["ignore"] = "me" + return child + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate. + AddData("foo", "bar"), + }, + }, + "error getting resources": { + Request: request, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("get", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + return defaultAggregateReconciler(c) + }, + }, + ShouldErr: true, + }, + "error creating resource": { + Request: request, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("create", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + return defaultAggregateReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeWarning, "CreationFailed", + `Failed to create ConfigMap %q: inducing failure for create ConfigMap`, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate. + AddData("foo", "bar"), + }, + ShouldErr: true, + }, + "error updating resource": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("update", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + return defaultAggregateReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeWarning, "UpdateFailed", + `Failed to update ConfigMap %q: inducing failure for update ConfigMap`, testName), + }, + ExpectUpdates: []client.Object{ + configMapGiven. + AddData("foo", "bar"), + }, + ShouldErr: true, + }, + "error deleting resource": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("delete", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.DesiredResource = func(ctx context.Context, resource *corev1.ConfigMap) (*corev1.ConfigMap, error) { + return nil, nil + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeWarning, "DeleteFailed", + `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + ShouldErr: true, + }, + "reconcile result": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven. + AddData("foo", "bar"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + SyncWithResult: func(ctx context.Context, resource *corev1.ConfigMap) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: time.Hour}, nil + }, + } + return r + }, + }, + ExpectedResult: reconcilers.Result{RequeueAfter: time.Hour}, + }, + "reconcile error": { + Request: request, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { + return fmt.Errorf("test error") + }, + } + return r + }, + }, + ShouldErr: true, + }, + "reconcile halted": { + Request: request, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Reconciler = reconcilers.Sequence[*corev1.ConfigMap]{ + &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { + return reconcilers.HaltSubReconcilers + }, + }, + &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { + return fmt.Errorf("test error") + }, + }, + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate. + AddData("foo", "bar"), + }, + }, + "reconcile halted with result": { + Request: request, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Reconciler = reconcilers.Sequence[*corev1.ConfigMap]{ + &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + SyncWithResult: func(ctx context.Context, resource *corev1.ConfigMap) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, reconcilers.HaltSubReconcilers + }, + }, + &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { + return fmt.Errorf("test error") + }, + }, + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate. + AddData("foo", "bar"), + }, + ExpectedResult: reconcilers.Result{Requeue: true}, + }, + "context is stashable": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven. + AddData("foo", "bar"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { + var key reconcilers.StashKey = "foo" + // StashValue will panic if the context is not setup correctly + reconcilers.StashValue(ctx, key, "bar") + return nil + }, + } + return r + }, + }, + }, + "context has config": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven. + AddData("foo", "bar"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { + if config := reconcilers.RetrieveConfigOrDie(ctx); config != c { + t.Errorf("expected config in context, found %#v", config) + } + if resourceConfig := reconcilers.RetrieveOriginalConfigOrDie(ctx); resourceConfig != c { + t.Errorf("expected original config in context, found %#v", resourceConfig) + } + return nil + }, + } + return r + }, + }, + }, + "context has resource type": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven. + AddData("foo", "bar"), + }, + Metadata: map[string]interface{}{ + "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { + if resourceType, ok := reconcilers.RetrieveOriginalResourceType(ctx).(*corev1.ConfigMap); !ok { + t.Errorf("expected original resource type not in context, found %#v", resourceType) + } + if resourceType, ok := reconcilers.RetrieveResourceType(ctx).(*corev1.ConfigMap); !ok { + t.Errorf("expected resource type not in context, found %#v", resourceType) + } + return nil + }, + } + return r + }, + }, + }, + "context can be augmented in Prepare and accessed in Cleanup": { + Request: request, + GivenObjects: []client.Object{ + configMapGiven. + AddData("foo", "bar"), + }, + Prepare: func(t *testing.T, ctx context.Context, tc *rtesting.ReconcilerTestCase) (context.Context, error) { + key := "test-key" + value := "test-value" + ctx = context.WithValue(ctx, key, value) + + tc.Metadata["Reconciler"] = func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { + r := defaultAggregateReconciler(c) + r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ + Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { + if v := ctx.Value(key); v != value { + t.Errorf("expected %s to be in context", key) + } + return nil + }, + } + return r + } + tc.CleanUp = func(t *testing.T, ctx context.Context, tc *rtesting.ReconcilerTestCase) error { + if v := ctx.Value(key); v != value { + t.Errorf("expected %s to be in context", key) + } + return nil + } + + return ctx, nil + }, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { + return rtc.Metadata["Reconciler"].(func(*testing.T, reconcilers.Config) reconcile.Reconciler)(t, c) + }) +} diff --git a/reconcilers/cast.go b/reconcilers/cast.go new file mode 100644 index 0000000..4f99199 --- /dev/null +++ b/reconcilers/cast.go @@ -0,0 +1,151 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "sync" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/equality" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + _ SubReconciler[client.Object] = (*CastResource[client.Object, client.Object])(nil) +) + +// CastResource casts the ResourceReconciler's type by projecting the resource data +// onto a new struct. Casting the reconciled resource is useful to create cross +// cutting reconcilers that can operate on common portion of multiple resources, +// commonly referred to as a duck type. +// +// If the CastType generic is an interface rather than a struct, the resource is +// passed directly rather than converted. +type CastResource[Type, CastType client.Object] struct { + // Name used to identify this reconciler. Defaults to `{Type}CastResource`. Ideally unique, but + // not required to be so. + // + // +optional + Name string + + // Reconciler is called for each reconciler request with the reconciled resource. Typically a + // Sequence is used to compose multiple SubReconcilers. + Reconciler SubReconciler[CastType] + + noop bool + lazyInit sync.Once +} + +func (r *CastResource[T, CT]) init() { + r.lazyInit.Do(func() { + var nilCT CT + if reflect.ValueOf(nilCT).Kind() == reflect.Invalid { + // not a real cast, just converting generic types + r.noop = true + return + } + emptyCT := newEmpty(nilCT) + if r.Name == "" { + r.Name = fmt.Sprintf("%sCastResource", typeName(emptyCT)) + } + }) +} + +func (r *CastResource[T, CT]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { + r.init() + + if !r.noop { + var nilCT CT + emptyCT := newEmpty(nilCT).(CT) + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name). + WithValues("castResourceType", typeName(emptyCT)) + ctx = logr.NewContext(ctx, log) + + if err := r.validate(ctx); err != nil { + return err + } + } + + return r.Reconciler.SetupWithManager(ctx, mgr, bldr) +} + +func (r *CastResource[T, CT]) validate(ctx context.Context) error { + // validate Reconciler value + if r.Reconciler == nil { + return fmt.Errorf("CastResource %q must define Reconciler", r.Name) + } + + return nil +} + +func (r *CastResource[T, CT]) Reconcile(ctx context.Context, resource T) (Result, error) { + r.init() + + if r.noop { + // cast the type rather than convert the object + return r.Reconciler.Reconcile(ctx, client.Object(resource).(CT)) + } + + var nilCT CT + emptyCT := newEmpty(nilCT).(CT) + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name). + WithValues("castResourceType", typeName(emptyCT)) + ctx = logr.NewContext(ctx, log) + + ctx, castResource, err := r.cast(ctx, resource) + if err != nil { + return Result{}, err + } + castOriginal := castResource.DeepCopyObject().(client.Object) + result, err := r.Reconciler.Reconcile(ctx, castResource) + if err != nil { + return result, err + } + if !equality.Semantic.DeepEqual(castResource, castOriginal) { + // patch the reconciled resource with the updated duck values + patch, err := NewPatch(castOriginal, castResource) + if err != nil { + return Result{}, err + } + err = patch.Apply(resource) + if err != nil { + return Result{}, err + } + + } + return result, nil +} + +func (r *CastResource[T, CT]) cast(ctx context.Context, resource T) (context.Context, CT, error) { + var nilCT CT + + data, err := json.Marshal(resource) + if err != nil { + return nil, nilCT, err + } + castResource := newEmpty(nilCT).(CT) + err = json.Unmarshal(data, castResource) + if err != nil { + return nil, nilCT, err + } + if kind := castResource.GetObjectKind(); kind.GroupVersionKind().Empty() { + // default the apiVersion/kind with the real value from the resource if not already defined + c := RetrieveConfigOrDie(ctx) + kind.SetGroupVersionKind(gvk(resource, c.Scheme())) + } + ctx = StashResourceType(ctx, castResource) + return ctx, castResource, nil +} diff --git a/reconcilers/cast_test.go b/reconcilers/cast_test.go new file mode 100644 index 0000000..d0a7624 --- /dev/null +++ b/reconcilers/cast_test.go @@ -0,0 +1,178 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers_test + +import ( + "context" + "fmt" + "testing" + + diecorev1 "dies.dev/apis/core/v1" + diemetav1 "dies.dev/apis/meta/v1" + "github.com/vmware-labs/reconciler-runtime/apis" + "github.com/vmware-labs/reconciler-runtime/internal/resources" + "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" + "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtesting "github.com/vmware-labs/reconciler-runtime/testing" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" +) + +func TestCastResource(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + _ = clientgoscheme.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), + ) + }) + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "sync success": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.TemplateDie(func(d *diecorev1.PodTemplateSpecDie) { + d.SpecDie(func(d *diecorev1.PodSpecDie) { + d.ContainerDie("test-container", func(d *diecorev1.ContainerDie) {}) + }) + }) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ + Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ + Sync: func(ctx context.Context, resource *appsv1.Deployment) error { + reconcilers.RetrieveConfigOrDie(ctx). + Recorder.Event(resource, corev1.EventTypeNormal, "Test", + resource.Spec.Template.Spec.Containers[0].Name) + return nil + }, + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Test", "test-container"), + }, + }, + "cast mutation": { + Resource: resource.DieReleasePtr(), + ExpectResource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.TemplateDie(func(d *diecorev1.PodTemplateSpecDie) { + d.MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name("mutation") + }) + }) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ + Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ + Sync: func(ctx context.Context, resource *appsv1.Deployment) error { + // mutation that exists on the original resource and will be reflected + resource.Spec.Template.Name = "mutation" + // mutation that does not exists on the original resource and will be dropped + resource.Spec.Paused = true + return nil + }, + }, + } + }, + }, + }, + "return subreconciler result": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ + Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ + SyncWithResult: func(ctx context.Context, resource *appsv1.Deployment) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{Requeue: true}, + }, + "return subreconciler err, preserves result": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ + Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ + SyncWithResult: func(ctx context.Context, resource *appsv1.Deployment) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, fmt.Errorf("subreconciler error") + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{Requeue: true}, + ShouldErr: true, + }, + "marshal error": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.ErrOnMarshal(true) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.CastResource[*resources.TestResource, *resources.TestResource]{ + Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + c.Recorder.Event(resource, corev1.EventTypeNormal, "Test", resource.Name) + return nil + }, + }, + } + }, + }, + ShouldErr: true, + }, + "unmarshal error": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.ErrOnUnmarshal(true) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.CastResource[*resources.TestResource, *resources.TestResource]{ + Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + c.Recorder.Event(resource, corev1.EventTypeNormal, "Test", resource.Name) + return nil + }, + }, + } + }, + }, + ShouldErr: true, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) + }) +} diff --git a/reconcilers/child.go b/reconcilers/child.go new file mode 100644 index 0000000..cc42fde --- /dev/null +++ b/reconcilers/child.go @@ -0,0 +1,378 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrs "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/vmware-labs/reconciler-runtime/internal" +) + +var ( + _ SubReconciler[client.Object] = (*ChildReconciler[client.Object, client.Object, client.ObjectList])(nil) +) + +var ( + OnlyReconcileChildStatus = errors.New("skip reconciler create/update/delete behavior for the child resource, while still reflecting the existing child's status on the reconciled resource") +) + +// ChildReconciler is a sub reconciler that manages a single child resource for a reconciled +// resource. The reconciler will ensure that exactly one child will match the desired state by: +// - creating a child if none exists +// - updating an existing child +// - removing an unneeded child +// - removing extra children +// +// The flow for each reconciliation request is: +// - DesiredChild +// - if child is desired, HarmonizeImmutableFields (optional) +// - if child is desired, MergeBeforeUpdate +// - ReflectChildStatusOnParent +// +// During setup, the child resource type is registered to watch for changes. +type ChildReconciler[Type, ChildType client.Object, ChildListType client.ObjectList] struct { + // Name used to identify this reconciler. Defaults to `{ChildType}ChildReconciler`. Ideally + // unique, but not required to be so. + // + // +optional + Name string + + // ChildType is the resource being created/updated/deleted by the reconciler. For example, a + // reconciled resource Deployment would have a ReplicaSet as a child. Required when the + // generic type is not a struct, or is unstructured. + // + // +optional + ChildType ChildType + // ChildListType is the listing type for the child type. For example, + // PodList is the list type for Pod. Required when the generic type is not + // a struct, or is unstructured. + // + // +optional + ChildListType ChildListType + + // Finalizer is set on the reconciled resource before a child resource is created, and cleared + // after a child resource is deleted. The value must be unique to this specific reconciler + // instance and not shared. Reusing a value may result in orphaned resources when the + // reconciled resource is deleted. + // + // Using a finalizer is encouraged when the Kubernetes garbage collector is unable to delete + // the child resource automatically, like when the reconciled resource and child are in different + // namespaces, scopes or clusters. + // + // Use of a finalizer implies that SkipOwnerReference is true, and OurChild must be defined. + // + // +optional + Finalizer string + + // SkipOwnerReference when true will not create and find child resources via an owner + // reference. OurChild must be defined for the reconciler to distinguish the child being + // reconciled from other resources of the same type. + // + // Any child resource created is tracked for changes. + SkipOwnerReference bool + + // Setup performs initialization on the manager and builder this reconciler + // will run with. It's common to setup field indexes and watch resources. + // + // +optional + Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error + + // DesiredChild returns the desired child object for the given reconciled resource, or nil if + // the child should not exist. + // + // To skip reconciliation of the child resource while still reflecting an existing child's + // status on the reconciled resource, return OnlyReconcileChildStatus as an error. + DesiredChild func(ctx context.Context, resource Type) (ChildType, error) + + // ReflectChildStatusOnParent updates the reconciled resource's status with values from the + // child. Select types of errors are passed, including: + // - apierrs.IsAlreadyExists + // + // Most errors are returned directly, skipping this method. The set of handled error types + // may grow, implementations should be defensive rather than assuming the error type. + ReflectChildStatusOnParent func(ctx context.Context, parent Type, child ChildType, err error) + + // HarmonizeImmutableFields allows fields that are immutable on the current + // object to be copied to the desired object in order to avoid creating + // updates which are guaranteed to fail. + // + // +optional + HarmonizeImmutableFields func(current, desired ChildType) + + // MergeBeforeUpdate copies desired fields on to the current object before + // calling update. Typically fields to copy are the Spec, Labels and + // Annotations. + MergeBeforeUpdate func(current, desired ChildType) + + // ListOptions allows custom options to be use when listing potential child resources. Each + // resource retrieved as part of the listing is confirmed via OurChild. + // + // Defaults to filtering by the reconciled resource's namespace: + // []client.ListOption{ + // client.InNamespace(resource.GetNamespace()), + // } + // + // +optional + ListOptions func(ctx context.Context, resource Type) []client.ListOption + + // OurChild is used when there are multiple ChildReconciler for the same ChildType controlled + // by the same reconciled resource. The function return true for child resources managed by + // this ChildReconciler. Objects returned from the DesiredChild function should match this + // function, otherwise they may be orphaned. If not specified, all children match. + // + // OurChild is required when a Finalizer is defined or SkipOwnerReference is true. + // + // +optional + OurChild func(resource Type, child ChildType) bool + + // Sanitize is called with an object before logging the value. Any value may + // be returned. A meaningful subset of the resource is typically returned, + // like the Spec. + // + // +optional + Sanitize func(child ChildType) interface{} + + stamp *ResourceManager[ChildType] + lazyInit sync.Once +} + +func (r *ChildReconciler[T, CT, CLT]) init() { + r.lazyInit.Do(func() { + if internal.IsNil(r.ChildType) { + var nilCT CT + r.ChildType = newEmpty(nilCT).(CT) + } + if internal.IsNil(r.ChildListType) { + var nilCLT CLT + r.ChildListType = newEmpty(nilCLT).(CLT) + } + if r.Name == "" { + r.Name = fmt.Sprintf("%sChildReconciler", typeName(r.ChildType)) + } + if r.stamp == nil { + r.stamp = &ResourceManager[CT]{ + Name: r.Name, + Type: r.ChildType, + Finalizer: r.Finalizer, + TrackDesired: r.SkipOwnerReference, + HarmonizeImmutableFields: r.HarmonizeImmutableFields, + MergeBeforeUpdate: r.MergeBeforeUpdate, + Sanitize: r.Sanitize, + } + } + }) +} + +func (r *ChildReconciler[T, CT, CLT]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { + r.init() + + c := RetrieveConfigOrDie(ctx) + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name). + WithValues("childType", gvk(r.ChildType, c.Scheme())) + ctx = logr.NewContext(ctx, log) + + if err := r.validate(ctx); err != nil { + return err + } + + if r.SkipOwnerReference { + bldr.Watches(r.ChildType, EnqueueTracked(ctx)) + } else { + bldr.Owns(r.ChildType) + } + + if r.Setup == nil { + return nil + } + return r.Setup(ctx, mgr, bldr) +} + +func (r *ChildReconciler[T, CT, CLT]) validate(ctx context.Context) error { + // default implicit values + if r.Finalizer != "" { + r.SkipOwnerReference = true + } + + // require DesiredChild + if r.DesiredChild == nil { + return fmt.Errorf("ChildReconciler %q must implement DesiredChild", r.Name) + } + + // require ReflectChildStatusOnParent + if r.ReflectChildStatusOnParent == nil { + return fmt.Errorf("ChildReconciler %q must implement ReflectChildStatusOnParent", r.Name) + } + + if r.OurChild == nil && r.SkipOwnerReference { + // OurChild is required when SkipOwnerReference is true + return fmt.Errorf("ChildReconciler %q must implement OurChild since owner references are not used", r.Name) + } + + return nil +} + +func (r *ChildReconciler[T, CT, CLT]) SetResourceManager(rm *ResourceManager[CT]) { + if r.stamp != nil { + panic(fmt.Errorf("cannot call SetResourceManager after a resource manager is defined")) + } + r.stamp = rm +} + +func (r *ChildReconciler[T, CT, CLT]) Reconcile(ctx context.Context, resource T) (Result, error) { + r.init() + + c := RetrieveConfigOrDie(ctx) + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name). + WithValues("childType", gvk(r.ChildType, c.Scheme())) + ctx = logr.NewContext(ctx, log) + + child, err := r.reconcile(ctx, resource) + if resource.GetDeletionTimestamp() != nil { + return Result{}, err + } + if err != nil { + if apierrs.IsAlreadyExists(err) { + // check if the resource blocking create is owned by the reconciled resource. + // the created child from a previous turn may be slow to appear in the informer cache, but shouldn't appear + // on the reconciled resource as being not ready. + apierr := err.(apierrs.APIStatus) + conflicted := r.ChildType.DeepCopyObject().(CT) + _ = c.APIReader.Get(ctx, types.NamespacedName{Namespace: resource.GetNamespace(), Name: apierr.Status().Details.Name}, conflicted) + if r.ourChild(resource, conflicted) { + // skip updating the reconciled resource's status, fail and try again + return Result{}, err + } + log.Info("unable to reconcile child, not owned", "child", namespaceName(conflicted), "ownerRefs", conflicted.GetOwnerReferences()) + r.ReflectChildStatusOnParent(ctx, resource, child, err) + return Result{}, nil + } + log.Error(err, "unable to reconcile child") + return Result{}, err + } + r.ReflectChildStatusOnParent(ctx, resource, child, err) + + return Result{}, nil +} + +func (r *ChildReconciler[T, CT, CLT]) reconcile(ctx context.Context, resource T) (CT, error) { + var nilCT CT + log := logr.FromContextOrDiscard(ctx) + pc := RetrieveOriginalConfigOrDie(ctx) + c := RetrieveConfigOrDie(ctx) + + actual := r.ChildType.DeepCopyObject().(CT) + children := r.ChildListType.DeepCopyObject().(CLT) + if err := c.List(ctx, children, r.listOptions(ctx, resource)...); err != nil { + return nilCT, err + } + items := r.filterChildren(resource, children) + if len(items) == 1 { + actual = items[0] + } else if len(items) > 1 { + // this shouldn't happen, delete everything to a clean slate + for _, extra := range items { + log.Info("deleting extra child", "child", namespaceName(extra)) + if err := c.Delete(ctx, extra); err != nil { + pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "DeleteFailed", + "Failed to delete %s %q: %v", typeName(r.ChildType), extra.GetName(), err) + return nilCT, err + } + pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Deleted", + "Deleted %s %q", typeName(r.ChildType), extra.GetName()) + } + } + + desired, err := r.desiredChild(ctx, resource) + if err != nil { + if errors.Is(err, OnlyReconcileChildStatus) { + return actual, nil + } + return nilCT, err + } + if !internal.IsNil(desired) { + if !r.SkipOwnerReference && metav1.GetControllerOfNoCopy(desired) == nil { + if err := ctrl.SetControllerReference(resource, desired, c.Scheme()); err != nil { + return nilCT, err + } + } + if !r.ourChild(resource, desired) { + log.Info("object returned from DesiredChild does not match OurChild, this can result in orphaned children", "child", namespaceName(desired)) + } + } + + // create/update/delete desired child + return r.stamp.Manage(ctx, resource, actual, desired) +} + +func (r *ChildReconciler[T, CT, CLT]) desiredChild(ctx context.Context, resource T) (CT, error) { + var nilCT CT + + if resource.GetDeletionTimestamp() != nil { + // the reconciled resource is pending deletion, cleanup the child resource + return nilCT, nil + } + + return r.DesiredChild(ctx, resource) +} + +func (r *ChildReconciler[T, CT, CLT]) filterChildren(resource T, children CLT) []CT { + items := []CT{} + for _, child := range extractItems[CT](children) { + if r.ourChild(resource, child) { + items = append(items, child) + } + } + return items +} + +func (r *ChildReconciler[T, CT, CLT]) listOptions(ctx context.Context, resource T) []client.ListOption { + if r.ListOptions == nil { + return []client.ListOption{ + client.InNamespace(resource.GetNamespace()), + } + } + return r.ListOptions(ctx, resource) +} + +func (r *ChildReconciler[T, CT, CLT]) ourChild(resource T, obj CT) bool { + if !r.SkipOwnerReference && !metav1.IsControlledBy(obj, resource) { + return false + } + // TODO do we need to remove resources pending deletion? + if r.OurChild == nil { + return true + } + return r.OurChild(resource, obj) +} + +// extractItems returns a typed slice of objects from an object list +func extractItems[T client.Object](list client.ObjectList) []T { + items := []T{} + listValue := reflect.ValueOf(list).Elem() + itemsValue := listValue.FieldByName("Items") + for i := 0; i < itemsValue.Len(); i++ { + item := itemsValue.Index(i).Addr().Interface().(T) + items = append(items, item) + } + return items +} diff --git a/reconcilers/child_test.go b/reconcilers/child_test.go new file mode 100644 index 0000000..4ee7e1e --- /dev/null +++ b/reconcilers/child_test.go @@ -0,0 +1,2038 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers_test + +import ( + "context" + "fmt" + "testing" + "time" + + diecorev1 "dies.dev/apis/core/v1" + diemetav1 "dies.dev/apis/meta/v1" + "github.com/vmware-labs/reconciler-runtime/apis" + "github.com/vmware-labs/reconciler-runtime/internal/resources" + "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" + "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtesting "github.com/vmware-labs/reconciler-runtime/testing" + corev1 "k8s.io/api/core/v1" + apierrs "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/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/pointer" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestChildReconciler(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testFinalizer := "test.finalizer" + + now := metav1.NewTime(time.Now().Truncate(time.Second)) + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + _ = clientgoscheme.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), + ) + }) + resourceReady := resource. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionTrue).Reason("Ready"), + ) + }) + + configMapCreate := diecorev1.ConfigMapBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + d.ControlledBy(resource, scheme) + }). + AddData("foo", "bar") + configMapGiven := configMapCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + }) + + defaultChildReconciler := func(c reconcilers.Config) *reconcilers.ChildReconciler[*resources.TestResource, *corev1.ConfigMap, *corev1.ConfigMapList] { + return &reconcilers.ChildReconciler[*resources.TestResource, *corev1.ConfigMap, *corev1.ConfigMapList]{ + DesiredChild: func(ctx context.Context, parent *resources.TestResource) (*corev1.ConfigMap, error) { + if len(parent.Spec.Fields) == 0 { + return nil, nil + } + + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: parent.Namespace, + Name: parent.Name, + }, + Data: reconcilers.MergeMaps(parent.Spec.Fields), + }, nil + }, + MergeBeforeUpdate: func(current, desired *corev1.ConfigMap) { + current.Data = desired.Data + }, + ReflectChildStatusOnParent: func(ctx context.Context, parent *resources.TestResource, child *corev1.ConfigMap, err error) { + if err != nil { + if apierrs.IsAlreadyExists(err) { + name := err.(apierrs.APIStatus).Status().Details.Name + parent.Status.MarkNotReady("NameConflict", "%q already exists", name) + } + return + } + if child == nil { + parent.Status.Fields = nil + parent.Status.MarkReady() + return + } + parent.Status.Fields = reconcilers.MergeMaps(child.Data) + parent.Status.MarkReady() + }, + } + } + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "preserve no child": { + Resource: resourceReady.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + }, + "child is in sync": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + }, + "child is in sync, in a different namespace": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace("other-ns") + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.ListOptions = func(ctx context.Context, parent *resources.TestResource) []client.ListOption { + return []client.ListOption{ + client.InNamespace("other-ns"), + } + } + return r + }, + }, + }, + "create child": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + ExpectCreates: []client.Object{ + configMapCreate, + }, + }, + "create child with custom owner reference": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + desiredChild := r.DesiredChild + r.DesiredChild = func(ctx context.Context, resource *resources.TestResource) (*corev1.ConfigMap, error) { + child, err := desiredChild(ctx, resource) + if child != nil { + child.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: resources.GroupVersion.String(), + Kind: "TestResource", + Name: resource.GetName(), + UID: resource.GetUID(), + Controller: pointer.Bool(true), + // the default controller ref is set to block + BlockOwnerDeletion: pointer.Bool(false), + }, + } + } + return child, err + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + ExpectCreates: []client.Object{ + configMapCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences( + metav1.OwnerReference{ + APIVersion: resources.GroupVersion.String(), + Kind: "TestResource", + Name: resource.GetName(), + UID: resource.GetUID(), + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(false), + }, + ) + }), + }, + }, + "create child with finalizer": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } + return r + }, + }, + ExpectTracks: []rtesting.TrackRequest{ + rtesting.NewTrackRequest(configMapCreate, resource, scheme), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", + `Patched finalizer %q`, testFinalizer), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer) + d.ResourceVersion("1000") + }). + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + ExpectCreates: []client.Object{ + configMapCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), + }, + }, + }, + "update child": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", + `Updated ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + DieReleasePtr(), + ExpectUpdates: []client.Object{ + configMapGiven. + AddData("new", "field"), + }, + }, + "update child, preserve finalizers": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer, "some.other.finalizer") + }). + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } + return r + }, + }, + ExpectTracks: []rtesting.TrackRequest{ + rtesting.NewTrackRequest(configMapGiven, resource, scheme), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", + `Updated ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer, "some.other.finalizer") + }). + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + DieReleasePtr(), + ExpectUpdates: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }). + AddData("new", "field"), + }, + }, + "update child, restoring missing finalizer": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } + return r + }, + }, + ExpectTracks: []rtesting.TrackRequest{ + rtesting.NewTrackRequest(configMapGiven, resource, scheme), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", + `Patched finalizer %q`, testFinalizer), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", + `Updated ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer) + d.ResourceVersion("1000") + }). + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + DieReleasePtr(), + ExpectUpdates: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }). + AddData("new", "field"), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), + }, + }, + }, + "delete child": { + Resource: resourceReady.DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + }, + "delete child, preserve finalizers": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer, "some.other.finalizer") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + }, + "ignore extraneous children": { + Resource: resourceReady.DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { + return false + } + return r + }, + }, + }, + "delete duplicate children": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name("extra-child-1") + }), + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name("extra-child-2") + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, "extra-child-1"), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, "extra-child-2"), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-1"}, + {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-2"}, + }, + ExpectCreates: []client.Object{ + configMapCreate, + }, + }, + "delete child during finalization": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + }, + "clear finalizer after child fully deleted": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", + `Patched finalizer %q`, testFinalizer), + }, + ExpectResource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers() + d.ResourceVersion("1000") + }). + DieReleasePtr(), + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), + }, + }, + }, + "preserve finalizer for terminating child": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } + return r + }, + }, + }, + "child name collision": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("create", "ConfigMap", rtesting.InduceFailureOpts{ + Error: apierrs.NewAlreadyExists(schema.GroupResource{}, testName), + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionFalse). + Reason("NameConflict").Message(`"test-resource" already exists`), + ) + }). + DieReleasePtr(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", + "Failed to create ConfigMap %q: %q already exists", testName, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate, + }, + }, + "child name collision, stale informer cache": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + APIGivenObjects: []client.Object{ + configMapGiven, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("create", "ConfigMap", rtesting.InduceFailureOpts{ + Error: apierrs.NewAlreadyExists(schema.GroupResource{}, testName), + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", + "Failed to create ConfigMap %q: %q already exists", testName, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate, + }, + ShouldErr: true, + }, + "preserve immutable fields": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + d.AddField("immutable", "field") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven. + AddData("immutable", "field"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.HarmonizeImmutableFields = func(current, desired *corev1.ConfigMap) { + desired.Data["immutable"] = current.Data["immutable"] + } + return r + }, + }, + }, + "status only reconcile": { + Resource: resource.DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven, + }, + ExpectResource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.DesiredChild = func(ctx context.Context, parent *resources.TestResource) (*corev1.ConfigMap, error) { + return nil, reconcilers.OnlyReconcileChildStatus + } + return r + }, + }, + }, + "sanitize child before logging": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Sanitize = func(child *corev1.ConfigMap) interface{} { + return child.Name + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + ExpectCreates: []client.Object{ + configMapCreate, + }, + }, + "sanitize is mutation safe": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Sanitize = func(child *corev1.ConfigMap) interface{} { + child.Data["ignore"] = "me" + return child + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + ExpectCreates: []client.Object{ + configMapCreate, + }, + }, + "error listing children": { + Resource: resourceReady.DieReleasePtr(), + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("list", "ConfigMapList"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ShouldErr: true, + }, + "error creating child": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("create", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", + `Failed to create ConfigMap %q: inducing failure for create ConfigMap`, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate, + }, + ShouldErr: true, + }, + "error adding finalizer": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } + return r + }, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("patch", "TestResource"), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", + `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), + }, + }, + ShouldErr: true, + }, + "error clearing finalizer": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } + return r + }, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("patch", "TestResource"), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", + `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), + }, + }, + ShouldErr: true, + }, + "error updating child": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("update", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "UpdateFailed", + `Failed to update ConfigMap %q: inducing failure for update ConfigMap`, testName), + }, + ExpectUpdates: []client.Object{ + configMapGiven. + AddData("new", "field"), + }, + ShouldErr: true, + }, + "error deleting child": { + Resource: resourceReady.DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("delete", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "DeleteFailed", + `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + ShouldErr: true, + }, + "error deleting duplicate children": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name("extra-child-1") + }), + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name("extra-child-2") + }), + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("delete", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "DeleteFailed", + `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, "extra-child-1"), + }, + ExpectDeletes: []rtesting.DeleteRef{ + {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-1"}, + }, + ShouldErr: true, + }, + "error creating desired child": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildReconciler(c) + r.DesiredChild = func(ctx context.Context, parent *resources.TestResource) (*corev1.ConfigMap, error) { + return nil, fmt.Errorf("test error") + } + return r + }, + }, + ShouldErr: true, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) + }) +} + +func TestChildReconciler_Unstructured(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testFinalizer := "test.finalizer" + + now := metav1.NewTime(time.Now().Truncate(time.Second)) + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + _ = clientgoscheme.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + APIVersion("testing.reconciler.runtime/v1"). + Kind("TestResource"). + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), + ) + }) + resourceReady := resource. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionTrue).Reason("Ready"), + ) + }) + + configMapCreate := diecorev1.ConfigMapBlank. + APIVersion("v1"). + Kind("ConfigMap"). + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + d.ControlledBy(resource, scheme) + }). + AddData("foo", "bar") + configMapGiven := configMapCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + }) + + defaultChildReconciler := func(c reconcilers.Config) *reconcilers.ChildReconciler[*unstructured.Unstructured, *unstructured.Unstructured, *unstructured.UnstructuredList] { + return &reconcilers.ChildReconciler[*unstructured.Unstructured, *unstructured.Unstructured, *unstructured.UnstructuredList]{ + ChildType: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + }, + }, + ChildListType: &unstructured.UnstructuredList{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMapList", + }, + }, + DesiredChild: func(ctx context.Context, parent *unstructured.Unstructured) (*unstructured.Unstructured, error) { + fields, ok, _ := unstructured.NestedMap(parent.Object, "spec", "fields") + if !ok || len(fields) == 0 { + return nil, nil + } + + child := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "namespace": parent.GetNamespace(), + "name": parent.GetName(), + }, + "data": map[string]interface{}{}, + }, + } + for k, v := range fields { + unstructured.SetNestedField(child.Object, v, "data", k) + } + + return child, nil + }, + MergeBeforeUpdate: func(current, desired *unstructured.Unstructured) { + current.Object["data"] = desired.Object["data"] + }, + ReflectChildStatusOnParent: func(ctx context.Context, parent *unstructured.Unstructured, child *unstructured.Unstructured, err error) { + if err != nil { + if apierrs.IsAlreadyExists(err) { + name := err.(apierrs.APIStatus).Status().Details.Name + readyCond := map[string]interface{}{ + "type": "Ready", + "status": "False", + "reason": "NameConflict", + "message": fmt.Sprintf("%q already exists", name), + } + unstructured.SetNestedSlice(parent.Object, []interface{}{readyCond}, "status", "conditions") + } + return + } + if child == nil { + unstructured.RemoveNestedField(parent.Object, "status", "fields") + readyCond := map[string]interface{}{ + "type": "Ready", + "status": "True", + "reason": "Ready", + "message": "", + } + unstructured.SetNestedSlice(parent.Object, []interface{}{readyCond}, "status", "conditions") + return + } + unstructured.SetNestedMap(parent.Object, map[string]interface{}{}, "status", "fields") + for k, v := range child.Object["data"].(map[string]interface{}) { + unstructured.SetNestedField(parent.Object, v, "status", "fields", k) + } + readyCond := map[string]interface{}{ + "type": "Ready", + "status": "True", + "reason": "Ready", + "message": "", + } + unstructured.SetNestedSlice(parent.Object, []interface{}{readyCond}, "status", "conditions") + }, + } + } + + rts := rtesting.SubReconcilerTests[*unstructured.Unstructured]{ + "preserve no child": { + Resource: resourceReady.DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + }, + "child is in sync": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + }, + "child is in sync, in a different namespace": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace("other-ns") + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.ListOptions = func(ctx context.Context, parent *unstructured.Unstructured) []client.ListOption { + return []client.ListOption{ + client.InNamespace("other-ns"), + } + } + return r + }, + }, + }, + "create child": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + ExpectCreates: []client.Object{ + configMapCreate.DieReleaseUnstructured(), + }, + }, + "create child with finalizer": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } + return r + }, + }, + ExpectTracks: []rtesting.TrackRequest{ + rtesting.NewTrackRequest(configMapCreate, resource, scheme), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", + `Patched finalizer %q`, testFinalizer), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer) + d.ResourceVersion("1000") + }). + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + ExpectCreates: []client.Object{ + configMapCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }). + DieReleaseUnstructured(), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), + }, + }, + }, + "update child": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", + `Updated ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + DieReleaseUnstructured(), + ExpectUpdates: []client.Object{ + configMapGiven. + AddData("new", "field"). + DieReleaseUnstructured(), + }, + }, + "update child, preserve finalizers": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer, "some.other.finalizer") + }). + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } + return r + }, + }, + ExpectTracks: []rtesting.TrackRequest{ + rtesting.NewTrackRequest(configMapGiven, resource, scheme), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", + `Updated ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer, "some.other.finalizer") + }). + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + DieReleaseUnstructured(), + ExpectUpdates: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }). + AddData("new", "field"). + DieReleaseUnstructured(), + }, + }, + "update child, restoring missing finalizer": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } + return r + }, + }, + ExpectTracks: []rtesting.TrackRequest{ + rtesting.NewTrackRequest(configMapGiven, resource, scheme), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", + `Patched finalizer %q`, testFinalizer), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", + `Updated ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer) + d.ResourceVersion("1000") + }). + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + DieReleaseUnstructured(), + ExpectUpdates: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }). + AddData("new", "field"). + DieReleaseUnstructured(), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), + }, + }, + }, + "delete child": { + Resource: resourceReady.DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + }, + "delete child, preserve finalizers": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer, "some.other.finalizer") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + }, + "ignore extraneous children": { + Resource: resourceReady.DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { + return false + } + return r + }, + }, + }, + "delete duplicate children": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name("extra-child-1") + }), + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name("extra-child-2") + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, "extra-child-1"), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, "extra-child-2"), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-1"}, + {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-2"}, + }, + ExpectCreates: []client.Object{ + configMapCreate.DieReleaseUnstructured(), + }, + }, + "delete child during finalization": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + }, + "clear finalizer after child fully deleted": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", + `Patched finalizer %q`, testFinalizer), + }, + ExpectResource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers() + d.ResourceVersion("1000") + }). + DieReleaseUnstructured(), + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":[],"resourceVersion":"999"}}`), + }, + }, + }, + "preserve finalizer for terminating child": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.OwnerReferences() + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } + return r + }, + }, + }, + "child name collision": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("create", "ConfigMap", rtesting.InduceFailureOpts{ + Error: apierrs.NewAlreadyExists(schema.GroupResource{}, testName), + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionFalse). + Reason("NameConflict").Message(`"test-resource" already exists`), + ) + }). + DieReleaseUnstructured(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", + "Failed to create ConfigMap %q: %q already exists", testName, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate.DieReleaseUnstructured(), + }, + }, + "child name collision, stale informer cache": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + APIGivenObjects: []client.Object{ + configMapGiven, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("create", "ConfigMap", rtesting.InduceFailureOpts{ + Error: apierrs.NewAlreadyExists(schema.GroupResource{}, testName), + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", + "Failed to create ConfigMap %q: %q already exists", testName, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate.DieReleaseUnstructured(), + }, + ShouldErr: true, + }, + "preserve immutable fields": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + d.AddField("immutable", "field") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven. + AddData("immutable", "field"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.HarmonizeImmutableFields = func(current, desired *unstructured.Unstructured) { + immutable, _, _ := unstructured.NestedString(current.Object, "data", "immutable") + unstructured.SetNestedField(desired.Object, immutable, "data", "immutable") + } + return r + }, + }, + }, + "status only reconcile": { + Resource: resource.DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven, + }, + ExpectResource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.DesiredChild = func(ctx context.Context, parent *unstructured.Unstructured) (*unstructured.Unstructured, error) { + return nil, reconcilers.OnlyReconcileChildStatus + } + return r + }, + }, + }, + "sanitize child before logging": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Sanitize = func(child *unstructured.Unstructured) interface{} { + return child.GetName() + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + ExpectCreates: []client.Object{ + configMapCreate.DieReleaseUnstructured(), + }, + }, + "sanitize is mutation safe": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Sanitize = func(child *unstructured.Unstructured) interface{} { + unstructured.SetNestedField(child.Object, "me", "data", "ignore") + return child + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + ExpectCreates: []client.Object{ + configMapCreate.DieReleaseUnstructured(), + }, + }, + "error listing children": { + Resource: resourceReady.DieReleaseUnstructured(), + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("list", "ConfigMapList"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ShouldErr: true, + }, + "error creating child": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("create", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", + `Failed to create ConfigMap %q: inducing failure for create ConfigMap`, testName), + }, + ExpectCreates: []client.Object{ + configMapCreate.DieReleaseUnstructured(), + }, + ShouldErr: true, + }, + "error adding finalizer": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } + return r + }, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("patch", "TestResource"), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", + `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), + }, + }, + ShouldErr: true, + }, + "error clearing finalizer": { + Resource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer) + }). + DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.Finalizer = testFinalizer + r.SkipOwnerReference = true + r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } + return r + }, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("patch", "TestResource"), + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", + `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":[],"resourceVersion":"999"}}`), + }, + }, + ShouldErr: true, + }, + "error updating child": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("update", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "UpdateFailed", + `Failed to update ConfigMap %q: inducing failure for update ConfigMap`, testName), + }, + ExpectUpdates: []client.Object{ + configMapGiven. + AddData("new", "field"). + DieReleaseUnstructured(), + }, + ShouldErr: true, + }, + "error deleting child": { + Resource: resourceReady.DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("delete", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "DeleteFailed", + `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven, scheme), + }, + ShouldErr: true, + }, + "error deleting duplicate children": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleaseUnstructured(), + GivenObjects: []client.Object{ + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name("extra-child-1") + }), + configMapGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name("extra-child-2") + }), + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("delete", "ConfigMap"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "DeleteFailed", + `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, "extra-child-1"), + }, + ExpectDeletes: []rtesting.DeleteRef{ + {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-1"}, + }, + ShouldErr: true, + }, + "error creating desired child": { + Resource: resource.DieReleaseUnstructured(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + r := defaultChildReconciler(c) + r.DesiredChild = func(ctx context.Context, parent *unstructured.Unstructured) (*unstructured.Unstructured, error) { + return nil, fmt.Errorf("test error") + } + return r + }, + }, + ShouldErr: true, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*unstructured.Unstructured], c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured])(t, c) + }) +} diff --git a/reconcilers/config.go b/reconcilers/config.go new file mode 100644 index 0000000..c3b884d --- /dev/null +++ b/reconcilers/config.go @@ -0,0 +1,180 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "fmt" + "strings" + "time" + + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/tools/reference" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + + "github.com/go-logr/logr" + "github.com/vmware-labs/reconciler-runtime/tracker" +) + +// Config holds common resources for controllers. The configuration may be +// passed to sub-reconcilers. +type Config struct { + client.Client + APIReader client.Reader + Recorder record.EventRecorder + Tracker tracker.Tracker +} + +func (c Config) IsEmpty() bool { + return c == Config{} +} + +// WithCluster extends the config to access a new cluster. +func (c Config) WithCluster(cluster cluster.Cluster) Config { + return Config{ + Client: cluster.GetClient(), + APIReader: cluster.GetAPIReader(), + Recorder: cluster.GetEventRecorderFor("controller"), + Tracker: c.Tracker, + } +} + +// TrackAndGet tracks the resources for changes and returns the current value. The track is +// registered even when the resource does not exists so that its creation can be tracked. +// +// Equivalent to calling both `c.Tracker.TrackObject(...)` and `c.Client.Get(...)` +func (c Config) TrackAndGet(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { + // create synthetic resource to track from known type and request + req := RetrieveRequest(ctx) + resource := RetrieveResourceType(ctx).DeepCopyObject().(client.Object) + resource.SetNamespace(req.Namespace) + resource.SetName(req.Name) + ref := obj.DeepCopyObject().(client.Object) + ref.SetNamespace(key.Namespace) + ref.SetName(key.Name) + c.Tracker.TrackObject(ref, resource) + + return c.Get(ctx, key, obj, opts...) +} + +// TrackAndList tracks the resources for changes and returns the current value. +// +// Equivalent to calling both `c.Tracker.TrackReference(...)` and `c.Client.List(...)` +func (c Config) TrackAndList(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + // create synthetic resource to track from known type and request + req := RetrieveRequest(ctx) + resource := RetrieveResourceType(ctx).DeepCopyObject().(client.Object) + resource.SetNamespace(req.Namespace) + resource.SetName(req.Name) + + or, err := reference.GetReference(c.Scheme(), list) + if err != nil { + return err + } + gvk := schema.FromAPIVersionAndKind(or.APIVersion, or.Kind) + listOpts := (&client.ListOptions{}).ApplyOptions(opts) + if listOpts.LabelSelector == nil { + listOpts.LabelSelector = labels.Everything() + } + ref := tracker.Reference{ + APIGroup: gvk.Group, + Kind: strings.TrimSuffix(gvk.Kind, "List"), + Namespace: listOpts.Namespace, + Selector: listOpts.LabelSelector, + } + c.Tracker.TrackReference(ref, resource) + + return c.List(ctx, list, opts...) +} + +// NewConfig creates a Config for a specific API type. Typically passed into a +// reconciler. +func NewConfig(mgr ctrl.Manager, apiType client.Object, syncPeriod time.Duration) Config { + return Config{ + Tracker: tracker.New(mgr.GetScheme(), 2*syncPeriod), + }.WithCluster(mgr) +} + +var ( + _ SubReconciler[client.Object] = (*WithConfig[client.Object])(nil) +) + +// Experimental: WithConfig injects the provided config into the reconcilers nested under it. For +// example, the client can be swapped to use a service account with different permissions, or to +// target an entirely different cluster. +// +// The specified config can be accessed with `RetrieveConfig(ctx)`, the original config used to +// load the reconciled resource can be accessed with `RetrieveOriginalConfig(ctx)`. +type WithConfig[Type client.Object] struct { + // Name used to identify this reconciler. Defaults to `WithConfig`. Ideally unique, but + // not required to be so. + // + // +optional + Name string + + // Config to use for this portion of the reconciler hierarchy. This method is called during + // setup and during reconciliation, if context is needed, it should be available durring both + // phases. + Config func(context.Context, Config) (Config, error) + + // Reconciler is called for each reconciler request with the reconciled + // resource being reconciled. Typically a Sequence is used to compose + // multiple SubReconcilers. + Reconciler SubReconciler[Type] +} + +func (r *WithConfig[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { + if r.Name == "" { + r.Name = "WithConfig" + } + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name) + ctx = logr.NewContext(ctx, log) + + if err := r.validate(ctx); err != nil { + return err + } + c, err := r.Config(ctx, RetrieveConfigOrDie(ctx)) + if err != nil { + return err + } + ctx = StashConfig(ctx, c) + return r.Reconciler.SetupWithManager(ctx, mgr, bldr) +} + +func (r *WithConfig[T]) validate(ctx context.Context) error { + // validate Config value + if r.Config == nil { + return fmt.Errorf("WithConfig %q must define Config", r.Name) + } + + // validate Reconciler value + if r.Reconciler == nil { + return fmt.Errorf("WithConfig %q must define Reconciler", r.Name) + } + + return nil +} + +func (r *WithConfig[T]) Reconcile(ctx context.Context, resource T) (Result, error) { + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name) + ctx = logr.NewContext(ctx, log) + + c, err := r.Config(ctx, RetrieveConfigOrDie(ctx)) + if err != nil { + return Result{}, err + } + ctx = StashConfig(ctx, c) + return r.Reconciler.Reconcile(ctx, resource) +} diff --git a/reconcilers/config_test.go b/reconcilers/config_test.go new file mode 100644 index 0000000..968cdb0 --- /dev/null +++ b/reconcilers/config_test.go @@ -0,0 +1,318 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers_test + +import ( + "context" + "fmt" + "testing" + + diecorev1 "dies.dev/apis/core/v1" + diemetav1 "dies.dev/apis/meta/v1" + "github.com/vmware-labs/reconciler-runtime/internal/resources" + "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" + "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtesting "github.com/vmware-labs/reconciler-runtime/testing" + "github.com/vmware-labs/reconciler-runtime/tracker" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestConfig_TrackAndGet(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + _ = clientgoscheme.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }) + + configMap := diecorev1.ConfigMapBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace("track-namespace") + d.Name("track-name") + }). + AddData("greeting", "hello") + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "track and get": { + Resource: resource.DieReleasePtr(), + GivenObjects: []client.Object{ + configMap, + }, + ExpectTracks: []rtesting.TrackRequest{ + rtesting.NewTrackRequest(configMap, resource, scheme), + }, + }, + "track with not found get": { + Resource: resource.DieReleasePtr(), + ShouldErr: true, + ExpectTracks: []rtesting.TrackRequest{ + rtesting.NewTrackRequest(configMap, resource, scheme), + }, + }, + } + + // run with typed objects + t.Run("typed", func(t *testing.T) { + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + c := reconcilers.RetrieveConfigOrDie(ctx) + + cm := &corev1.ConfigMap{} + err := c.TrackAndGet(ctx, types.NamespacedName{Namespace: "track-namespace", Name: "track-name"}, cm) + if err != nil { + return err + } + + if expected, actual := "hello", cm.Data["greeting"]; expected != actual { + // should never get here + panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) + } + return nil + }, + } + }) + }) + + // run with unstructured objects + t.Run("unstructured", func(t *testing.T) { + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + c := reconcilers.RetrieveConfigOrDie(ctx) + + cm := &unstructured.Unstructured{} + cm.SetAPIVersion("v1") + cm.SetKind("ConfigMap") + err := c.TrackAndGet(ctx, types.NamespacedName{Namespace: "track-namespace", Name: "track-name"}, cm) + if err != nil { + return err + } + + if expected, actual := "hello", cm.UnstructuredContent()["data"].(map[string]interface{})["greeting"].(string); expected != actual { + // should never get here + panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) + } + return nil + }, + } + }) + }) +} + +func TestConfig_TrackAndList(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testSelector, _ := labels.Parse("app=test-app") + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + _ = clientgoscheme.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }) + + configMap := diecorev1.ConfigMapBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace("track-namespace") + d.Name("track-name") + d.AddLabel("app", "test-app") + }). + AddData("greeting", "hello") + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "track and list": { + Resource: resource.DieReleasePtr(), + GivenObjects: []client.Object{ + configMap, + }, + Metadata: map[string]interface{}{ + "listOpts": []client.ListOption{}, + }, + ExpectTracks: []rtesting.TrackRequest{ + { + Tracker: types.NamespacedName{ + Namespace: testNamespace, + Name: testName, + }, + TrackedReference: tracker.Reference{ + Kind: "ConfigMap", + Selector: labels.Everything(), + }, + }, + }, + }, + "track and list constrained": { + Resource: resource.DieReleasePtr(), + GivenObjects: []client.Object{ + configMap, + }, + Metadata: map[string]interface{}{ + "listOpts": []client.ListOption{ + client.InNamespace("track-namespace"), + client.MatchingLabels(map[string]string{"app": "test-app"}), + }, + }, + ExpectTracks: []rtesting.TrackRequest{ + { + Tracker: types.NamespacedName{ + Namespace: testNamespace, + Name: testName, + }, + TrackedReference: tracker.Reference{ + Kind: "ConfigMap", + Namespace: "track-namespace", + Selector: testSelector, + }, + }, + }, + }, + "track with errored list": { + Resource: resource.DieReleasePtr(), + ShouldErr: true, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("list", "ConfigMapList"), + }, + Metadata: map[string]interface{}{ + "listOpts": []client.ListOption{}, + }, + ExpectTracks: []rtesting.TrackRequest{ + { + Tracker: types.NamespacedName{ + Namespace: testNamespace, + Name: testName, + }, + TrackedReference: tracker.Reference{ + Kind: "ConfigMap", + Selector: labels.Everything(), + }, + }, + }, + }, + } + + // run with typed objects + t.Run("typed", func(t *testing.T) { + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + c := reconcilers.RetrieveConfigOrDie(ctx) + + cms := &corev1.ConfigMapList{} + listOpts := rtc.Metadata["listOpts"].([]client.ListOption) + err := c.TrackAndList(ctx, cms, listOpts...) + if err != nil { + return err + } + + if expected, actual := "hello", cms.Items[0].Data["greeting"]; expected != actual { + // should never get here + panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) + } + return nil + }, + } + }) + }) + + // run with unstructured objects + t.Run("unstructured", func(t *testing.T) { + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + c := reconcilers.RetrieveConfigOrDie(ctx) + + cms := &unstructured.UnstructuredList{} + cms.SetAPIVersion("v1") + cms.SetKind("ConfigMapList") + listOpts := rtc.Metadata["listOpts"].([]client.ListOption) + err := c.TrackAndList(ctx, cms, listOpts...) + if err != nil { + return err + } + + if expected, actual := "hello", cms.UnstructuredContent()["items"].([]interface{})[0].(map[string]interface{})["data"].(map[string]interface{})["greeting"].(string); expected != actual { + // should never get here + panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) + } + return nil + }, + } + }) + }) +} + +func TestWithConfig(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }) + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "with config": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, oc reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + c := reconcilers.Config{ + Tracker: tracker.New(oc.Scheme(), 0), + } + + return &reconcilers.WithConfig[*resources.TestResource]{ + Config: func(ctx context.Context, _ reconcilers.Config) (reconcilers.Config, error) { + return c, nil + }, + Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, parent *resources.TestResource) error { + rc := reconcilers.RetrieveConfigOrDie(ctx) + roc := reconcilers.RetrieveOriginalConfigOrDie(ctx) + + if rc != c { + t.Errorf("unexpected config") + } + if roc != oc { + t.Errorf("unexpected original config") + } + + oc.Recorder.Event(resource, corev1.EventTypeNormal, "AllGood", "") + + return nil + }, + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "AllGood", ""), + }, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) + }) +} diff --git a/reconcilers/enqueuer.go b/reconcilers/enqueuer.go deleted file mode 100644 index 5749807..0000000 --- a/reconcilers/enqueuer.go +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2020 VMware, Inc. -SPDX-License-Identifier: Apache-2.0 -*/ - -package reconcilers - -import ( - "context" - - "github.com/go-logr/logr" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/handler" -) - -func EnqueueTracked(ctx context.Context) handler.EventHandler { - c := RetrieveConfigOrDie(ctx) - log := logr.FromContextOrDiscard(ctx) - - return handler.EnqueueRequestsFromMapFunc( - func(ctx context.Context, obj client.Object) []Request { - var requests []Request - - items, err := c.Tracker.GetObservers(obj) - if err != nil { - log.Error(err, "unable to get tracked requests") - return nil - } - - for _, item := range items { - requests = append(requests, Request{NamespacedName: item}) - } - - return requests - }, - ) -} diff --git a/reconcilers/finalizer.go b/reconcilers/finalizer.go new file mode 100644 index 0000000..c662b85 --- /dev/null +++ b/reconcilers/finalizer.go @@ -0,0 +1,165 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +var ( + _ SubReconciler[client.Object] = (*WithFinalizer[client.Object])(nil) +) + +// WithFinalizer ensures the resource being reconciled has the desired finalizer set so that state +// can be cleaned up upon the resource being deleted. The finalizer is added to the resource, if not +// already set, before calling the nested reconciler. When the resource is terminating, the +// finalizer is cleared after returning from the nested reconciler without error. +type WithFinalizer[Type client.Object] struct { + // Name used to identify this reconciler. Defaults to `WithFinalizer`. Ideally unique, but + // not required to be so. + // + // +optional + Name string + + // Finalizer to set on the reconciled resource. The value must be unique to this specific + // reconciler instance and not shared. Reusing a value may result in orphaned state when + // the reconciled resource is deleted. + // + // Using a finalizer is encouraged when state needs to be manually cleaned up before a resource + // is fully deleted. This commonly include state allocated outside of the current cluster. + Finalizer string + + // Reconciler is called for each reconciler request with the reconciled + // resource being reconciled. Typically a Sequence is used to compose + // multiple SubReconcilers. + Reconciler SubReconciler[Type] +} + +func (r *WithFinalizer[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { + if r.Name == "" { + r.Name = "WithFinalizer" + } + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name) + ctx = logr.NewContext(ctx, log) + + if err := r.validate(ctx); err != nil { + return err + } + return r.Reconciler.SetupWithManager(ctx, mgr, bldr) +} + +func (r *WithFinalizer[T]) validate(ctx context.Context) error { + // validate Finalizer value + if r.Finalizer == "" { + return fmt.Errorf("WithFinalizer %q must define Finalizer", r.Name) + } + + // validate Reconciler value + if r.Reconciler == nil { + return fmt.Errorf("WithFinalizer %q must define Reconciler", r.Name) + } + + return nil +} + +func (r *WithFinalizer[T]) Reconcile(ctx context.Context, resource T) (Result, error) { + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name) + ctx = logr.NewContext(ctx, log) + + if resource.GetDeletionTimestamp() == nil { + if err := AddFinalizer(ctx, resource, r.Finalizer); err != nil { + return Result{}, err + } + } + result, err := r.Reconciler.Reconcile(ctx, resource) + if err != nil { + return result, err + } + if resource.GetDeletionTimestamp() != nil { + if err := ClearFinalizer(ctx, resource, r.Finalizer); err != nil { + return Result{}, err + } + } + return result, err +} + +// AddFinalizer ensures the desired finalizer exists on the reconciled resource. The client that +// loaded the reconciled resource is used to patch it with the finalizer if not already set. +func AddFinalizer(ctx context.Context, resource client.Object, finalizer string) error { + return ensureFinalizer(ctx, resource, finalizer, true) +} + +// ClearFinalizer ensures the desired finalizer does not exist on the reconciled resource. The +// client that loaded the reconciled resource is used to patch it with the finalizer if set. +func ClearFinalizer(ctx context.Context, resource client.Object, finalizer string) error { + return ensureFinalizer(ctx, resource, finalizer, false) +} + +func ensureFinalizer(ctx context.Context, resource client.Object, finalizer string, add bool) error { + config := RetrieveOriginalConfigOrDie(ctx) + if config.IsEmpty() { + panic(fmt.Errorf("resource config must exist on the context. Check that the context from a ResourceReconciler")) + } + resourceType := RetrieveOriginalResourceType(ctx) + if resourceType == nil { + panic(fmt.Errorf("resource type must exist on the context. Check that the context from a ResourceReconciler")) + } + + if finalizer == "" || controllerutil.ContainsFinalizer(resource, finalizer) == add { + // nothing to do + return nil + } + + // cast the current object back to the resource so scheme-aware, typed client can operate on it + cast := &CastResource[client.Object, client.Object]{ + Reconciler: &SyncReconciler[client.Object]{ + SyncDuringFinalization: true, + Sync: func(ctx context.Context, current client.Object) error { + log := logr.FromContextOrDiscard(ctx) + + desired := current.DeepCopyObject().(client.Object) + if add { + log.Info("adding finalizer", "finalizer", finalizer) + controllerutil.AddFinalizer(desired, finalizer) + } else { + log.Info("removing finalizer", "finalizer", finalizer) + controllerutil.RemoveFinalizer(desired, finalizer) + } + + patch := client.MergeFromWithOptions(current, client.MergeFromWithOptimisticLock{}) + if err := config.Patch(ctx, desired, patch); err != nil { + log.Error(err, "unable to patch finalizers", "finalizer", finalizer) + config.Recorder.Eventf(current, corev1.EventTypeWarning, "FinalizerPatchFailed", + "Failed to patch finalizer %q: %s", finalizer, err) + return err + } + config.Recorder.Eventf(current, corev1.EventTypeNormal, "FinalizerPatched", + "Patched finalizer %q", finalizer) + + // update current object with values from the api server after patching + current.SetFinalizers(desired.GetFinalizers()) + current.SetResourceVersion(desired.GetResourceVersion()) + current.SetGeneration(desired.GetGeneration()) + + return nil + }, + }, + } + + _, err := cast.Reconcile(ctx, resource) + return err +} diff --git a/reconcilers/finalizer_test.go b/reconcilers/finalizer_test.go new file mode 100644 index 0000000..b9242f0 --- /dev/null +++ b/reconcilers/finalizer_test.go @@ -0,0 +1,193 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers_test + +import ( + "context" + "fmt" + "testing" + "time" + + diemetav1 "dies.dev/apis/meta/v1" + "github.com/vmware-labs/reconciler-runtime/internal/resources" + "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" + "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtesting "github.com/vmware-labs/reconciler-runtime/testing" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +func TestWithFinalizer(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testFinalizer := "test-finalizer" + + now := &metav1.Time{Time: time.Now().Truncate(time.Second)} + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }) + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "in sync": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Sync", ""), + }, + }, + "add finalizer": { + Resource: resource.DieReleasePtr(), + ExpectResource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Finalizers(testFinalizer) + d.ResourceVersion("1000") + }). + DieReleasePtr(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", + `Patched finalizer %q`, testFinalizer), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Sync", ""), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":["test-finalizer"],"resourceVersion":"999"}}`), + }, + }, + }, + "error adding finalizer": { + Resource: resource.DieReleasePtr(), + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("patch", "TestResource"), + }, + ShouldErr: true, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", + `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":["test-finalizer"],"resourceVersion":"999"}}`), + }, + }, + }, + "clear finalizer": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + ExpectResource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(now) + d.ResourceVersion("1000") + }). + DieReleasePtr(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Finalize", ""), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", + `Patched finalizer %q`, testFinalizer), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), + }, + }, + }, + "error clearing finalizer": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("patch", "TestResource"), + }, + ShouldErr: true, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Finalize", ""), + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", + `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), + }, + ExpectPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: testNamespace, + Name: testName, + PatchType: types.MergePatchType, + Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), + }, + }, + }, + "keep finalizer on error": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + ShouldErr: true, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Finalize", ""), + }, + Metadata: map[string]interface{}{ + "FinalizerError": fmt.Errorf("finalize error"), + }, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + var syncErr, finalizeErr error + if err, ok := rtc.Metadata["SyncError"]; ok { + syncErr = err.(error) + } + if err, ok := rtc.Metadata["FinalizerError"]; ok { + finalizeErr = err.(error) + } + + return &reconcilers.WithFinalizer[*resources.TestResource]{ + Finalizer: testFinalizer, + Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + c.Recorder.Event(resource, corev1.EventTypeNormal, "Sync", "") + return syncErr + }, + Finalize: func(ctx context.Context, resource *resources.TestResource) error { + c.Recorder.Event(resource, corev1.EventTypeNormal, "Finalize", "") + return finalizeErr + }, + }, + } + }) +} diff --git a/reconcilers/patch.go b/reconcilers/patch.go deleted file mode 100644 index def6c25..0000000 --- a/reconcilers/patch.go +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright 2020 VMware, Inc. -SPDX-License-Identifier: Apache-2.0 -*/ - -package reconcilers - -import ( - "encoding/json" - "errors" - - jsonpatch "github.com/evanphx/json-patch/v5" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -func NewPatch(base, update client.Object) (*Patch, error) { - baseBytes, err := json.Marshal(base) - if err != nil { - return nil, err - } - updateBytes, err := json.Marshal(update) - if err != nil { - return nil, err - } - patch, err := jsonpatch.CreateMergePatch(baseBytes, updateBytes) - if err != nil { - return nil, err - } - - return &Patch{ - generation: base.GetGeneration(), - bytes: patch, - }, nil -} - -type Patch struct { - generation int64 - bytes []byte -} - -var PatchGenerationMismatch = errors.New("patch generation did not match target") - -func (p *Patch) Apply(rebase client.Object) error { - if rebase.GetGeneration() != p.generation { - return PatchGenerationMismatch - } - - rebaseBytes, err := json.Marshal(rebase) - if err != nil { - return err - } - patchedBytes, err := jsonpatch.MergePatch(rebaseBytes, p.bytes) - if err != nil { - return err - } - // reset rebase to its empty value before unmarshaling into it - replaceWithEmpty(rebase) - return json.Unmarshal(patchedBytes, rebase) -} diff --git a/reconcilers/reconcilers.go b/reconcilers/reconcilers.go index b37b86c..173fe92 100644 --- a/reconcilers/reconcilers.go +++ b/reconcilers/reconcilers.go @@ -7,673 +7,35 @@ package reconcilers import ( "context" - "encoding/json" "errors" "fmt" "reflect" - "strings" - "sync" - "time" "github.com/go-logr/logr" - "github.com/google/go-cmp/cmp" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/equality" - apierrs "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/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/cache" - "k8s.io/client-go/tools/record" - "k8s.io/client-go/tools/reference" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/cluster" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/webhook" - - "github.com/vmware-labs/reconciler-runtime/internal" - "github.com/vmware-labs/reconciler-runtime/tracker" -) - -var ( - _ reconcile.Reconciler = (*ResourceReconciler[client.Object])(nil) - _ reconcile.Reconciler = (*AggregateReconciler[client.Object])(nil) + "sigs.k8s.io/controller-runtime/pkg/handler" ) -// Config holds common resources for controllers. The configuration may be -// passed to sub-reconcilers. -type Config struct { - client.Client - APIReader client.Reader - Recorder record.EventRecorder - Tracker tracker.Tracker -} - -func (c Config) IsEmpty() bool { - return c == Config{} -} - -// WithCluster extends the config to access a new cluster. -func (c Config) WithCluster(cluster cluster.Cluster) Config { - return Config{ - Client: cluster.GetClient(), - APIReader: cluster.GetAPIReader(), - Recorder: cluster.GetEventRecorderFor("controller"), - Tracker: c.Tracker, - } -} - -// TrackAndGet tracks the resources for changes and returns the current value. The track is -// registered even when the resource does not exists so that its creation can be tracked. -// -// Equivalent to calling both `c.Tracker.TrackObject(...)` and `c.Client.Get(...)` -func (c Config) TrackAndGet(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error { - // create synthetic resource to track from known type and request - req := RetrieveRequest(ctx) - resource := RetrieveResourceType(ctx).DeepCopyObject().(client.Object) - resource.SetNamespace(req.Namespace) - resource.SetName(req.Name) - ref := obj.DeepCopyObject().(client.Object) - ref.SetNamespace(key.Namespace) - ref.SetName(key.Name) - c.Tracker.TrackObject(ref, resource) - - return c.Get(ctx, key, obj, opts...) -} - -// TrackAndList tracks the resources for changes and returns the current value. -// -// Equivalent to calling both `c.Tracker.TrackReference(...)` and `c.Client.List(...)` -func (c Config) TrackAndList(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { - // create synthetic resource to track from known type and request - req := RetrieveRequest(ctx) - resource := RetrieveResourceType(ctx).DeepCopyObject().(client.Object) - resource.SetNamespace(req.Namespace) - resource.SetName(req.Name) - - or, err := reference.GetReference(c.Scheme(), list) - if err != nil { - return err - } - gvk := schema.FromAPIVersionAndKind(or.APIVersion, or.Kind) - listOpts := (&client.ListOptions{}).ApplyOptions(opts) - if listOpts.LabelSelector == nil { - listOpts.LabelSelector = labels.Everything() - } - ref := tracker.Reference{ - APIGroup: gvk.Group, - Kind: strings.TrimSuffix(gvk.Kind, "List"), - Namespace: listOpts.Namespace, - Selector: listOpts.LabelSelector, - } - c.Tracker.TrackReference(ref, resource) - - return c.List(ctx, list, opts...) -} - -// NewConfig creates a Config for a specific API type. Typically passed into a -// reconciler. -func NewConfig(mgr ctrl.Manager, apiType client.Object, syncPeriod time.Duration) Config { - return Config{ - Tracker: tracker.New(mgr.GetScheme(), 2*syncPeriod), - }.WithCluster(mgr) -} - -// ResourceReconciler is a controller-runtime reconciler that reconciles a given -// existing resource. The Type resource is fetched for the reconciler -// request and passed in turn to each SubReconciler. Finally, the reconciled -// resource's status is compared with the original status, updating the API -// server if needed. -type ResourceReconciler[Type client.Object] struct { - // Name used to identify this reconciler. Defaults to `{Type}ResourceReconciler`. Ideally - // unique, but not required to be so. - // - // +optional - Name string - - // Setup performs initialization on the manager and builder this reconciler - // will run with. It's common to setup field indexes and watch resources. - // - // +optional - Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error - - // Type of resource to reconcile. Required when the generic type is not a - // struct, or is unstructured. - // - // +optional - Type Type - - // SkipStatusUpdate when true, the resource's status will not be updated. If this - // is not the primary reconciler for a resource, skipping status updates can avoid - // conflicts. Finalizers and events are still actionable. - SkipStatusUpdate bool - - // Reconciler is called for each reconciler request with the resource being reconciled. - // Typically, Reconciler is a Sequence of multiple SubReconcilers. - // - // When HaltSubReconcilers is returned as an error, execution continues as if no error was - // returned. - Reconciler SubReconciler[Type] - - Config Config - - lazyInit sync.Once -} - -func (r *ResourceReconciler[T]) init() { - r.lazyInit.Do(func() { - if internal.IsNil(r.Type) { - var nilT T - r.Type = newEmpty(nilT).(T) - } - if r.Name == "" { - r.Name = fmt.Sprintf("%sResourceReconciler", typeName(r.Type)) - } - }) -} - -func (r *ResourceReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { - _, err := r.SetupWithManagerYieldingController(ctx, mgr) - return err -} - -func (r *ResourceReconciler[T]) SetupWithManagerYieldingController(ctx context.Context, mgr ctrl.Manager) (controller.Controller, error) { - r.init() - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name). - WithValues("resourceType", gvk(r.Type, r.Config.Scheme())) - ctx = logr.NewContext(ctx, log) - - ctx = StashConfig(ctx, r.Config) - ctx = StashOriginalConfig(ctx, r.Config) - ctx = StashResourceType(ctx, r.Type) - ctx = StashOriginalResourceType(ctx, r.Type) - - if err := r.validate(ctx); err != nil { - return nil, err - } - - bldr := ctrl.NewControllerManagedBy(mgr).For(r.Type) - if r.Setup != nil { - if err := r.Setup(ctx, mgr, bldr); err != nil { - return nil, err - } - } - if err := r.Reconciler.SetupWithManager(ctx, mgr, bldr); err != nil { - return nil, err - } - return bldr.Build(r) -} - -func (r *ResourceReconciler[T]) validate(ctx context.Context) error { - // validate Reconciler value - if r.Reconciler == nil { - return fmt.Errorf("ResourceReconciler %q must define Reconciler", r.Name) - } - - // warn users of common pitfalls. These are not blockers. - - log := logr.FromContextOrDiscard(ctx) - - resourceType := reflect.TypeOf(r.Type).Elem() - statusField, hasStatus := resourceType.FieldByName("Status") - if !hasStatus { - log.Info("resource missing status field, operations related to status will be skipped") - return nil - } - - statusType := statusField.Type - if statusType.Kind() == reflect.Ptr { - log.Info("resource status is nilable, status is typically a struct") - statusType = statusType.Elem() - } - - observedGenerationField, hasObservedGeneration := statusType.FieldByName("ObservedGeneration") - if !hasObservedGeneration || observedGenerationField.Type.Kind() != reflect.Int64 { - log.Info("resource status missing ObservedGeneration field of type int64, generation will not be managed") - } - - initializeConditionsMethod, hasInitializeConditions := reflect.PtrTo(statusType).MethodByName("InitializeConditions") - if !hasInitializeConditions || initializeConditionsMethod.Type.NumIn() != 1 || initializeConditionsMethod.Type.NumOut() != 0 { - log.Info("resource status missing InitializeConditions() method, conditions will not be auto-initialized") - } - - conditionsField, hasConditions := statusType.FieldByName("Conditions") - if !hasConditions || !conditionsField.Type.AssignableTo(reflect.TypeOf([]metav1.Condition{})) { - log.Info("resource status is missing field Conditions of type []metav1.Condition, condition timestamps will not be managed") - } - - return nil -} - -func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req Request) (Result, error) { - r.init() - - ctx = WithStash(ctx) - - c := r.Config - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name). - WithValues("resourceType", gvk(r.Type, c.Scheme())) - ctx = logr.NewContext(ctx, log) - - ctx = StashRequest(ctx, req) - ctx = StashConfig(ctx, c) - ctx = StashOriginalConfig(ctx, c) - ctx = StashOriginalResourceType(ctx, r.Type) - ctx = StashResourceType(ctx, r.Type) - originalResource := r.Type.DeepCopyObject().(T) - - if err := c.Get(ctx, req.NamespacedName, originalResource); err != nil { - if apierrs.IsNotFound(err) { - // we'll ignore not-found errors, since they can't be fixed by an immediate - // requeue (we'll need to wait for a new notification), and we can get them - // on deleted requests. - return Result{}, nil - } - log.Error(err, "unable to fetch resource") - return Result{}, err - } - resource := originalResource.DeepCopyObject().(T) - - if defaulter, ok := client.Object(resource).(webhook.Defaulter); ok { - // resource.Default() - defaulter.Default() - } - - r.initializeConditions(resource) - result, err := r.reconcile(ctx, resource) - - if r.SkipStatusUpdate { - return result, err - } - - // attempt to restore last transition time for unchanged conditions - r.syncLastTransitionTime(r.conditions(resource), r.conditions(originalResource)) - - // check if status has changed before updating - resourceStatus, originalResourceStatus := r.status(resource), r.status(originalResource) - if !equality.Semantic.DeepEqual(resourceStatus, originalResourceStatus) && resource.GetDeletionTimestamp() == nil { - // update status - log.Info("updating status", "diff", cmp.Diff(originalResourceStatus, resourceStatus)) - if updateErr := c.Status().Update(ctx, resource); updateErr != nil { - log.Error(updateErr, "unable to update status") - c.Recorder.Eventf(resource, corev1.EventTypeWarning, "StatusUpdateFailed", - "Failed to update status: %v", updateErr) - return Result{}, updateErr - } - c.Recorder.Eventf(resource, corev1.EventTypeNormal, "StatusUpdated", - "Updated status") - } - - // return original reconcile result - return result, err -} - -func (r *ResourceReconciler[T]) reconcile(ctx context.Context, resource T) (Result, error) { - if resource.GetDeletionTimestamp() != nil && len(resource.GetFinalizers()) == 0 { - // resource is being deleted and has no pending finalizers, nothing to do - return Result{}, nil - } - - result, err := r.Reconciler.Reconcile(ctx, resource) - if err != nil && !errors.Is(err, HaltSubReconcilers) { - return Result{}, err - } - - r.copyGeneration(resource) - return result, nil -} - -func (r *ResourceReconciler[T]) initializeConditions(obj T) { - status := r.status(obj) - if status == nil { - return - } - initializeConditions := reflect.ValueOf(status).MethodByName("InitializeConditions") - if !initializeConditions.IsValid() { - return - } - if t := initializeConditions.Type(); t.Kind() != reflect.Func || t.NumIn() != 0 || t.NumOut() != 0 { - return - } - initializeConditions.Call([]reflect.Value{}) -} - -func (r *ResourceReconciler[T]) conditions(obj T) []metav1.Condition { - // return obj.Status.Conditions - status := r.status(obj) - if status == nil { - return nil - } - statusValue := reflect.ValueOf(status) - if statusValue.Type().Kind() == reflect.Map { - return nil - } - statusValue = statusValue.Elem() - conditionsValue := statusValue.FieldByName("Conditions") - if !conditionsValue.IsValid() || conditionsValue.IsZero() { - return nil - } - conditions, ok := conditionsValue.Interface().([]metav1.Condition) - if !ok { - return nil - } - return conditions -} - -func (r *ResourceReconciler[T]) copyGeneration(obj T) { - // obj.Status.ObservedGeneration = obj.Generation - status := r.status(obj) - if status == nil { - return - } - statusValue := reflect.ValueOf(status) - if statusValue.Type().Kind() == reflect.Map { - return - } - statusValue = statusValue.Elem() - if !statusValue.IsValid() { - return - } - observedGenerationValue := statusValue.FieldByName("ObservedGeneration") - if observedGenerationValue.Kind() != reflect.Int64 || !observedGenerationValue.CanSet() { - return - } - generation := obj.GetGeneration() - observedGenerationValue.SetInt(generation) -} - -func (r *ResourceReconciler[T]) hasStatus(obj T) bool { - status := r.status(obj) - return status != nil -} - -func (r *ResourceReconciler[T]) status(obj T) interface{} { - if client.Object(obj) == nil { - return nil - } - if u, ok := client.Object(obj).(*unstructured.Unstructured); ok { - return u.UnstructuredContent()["status"] - } - statusValue := reflect.ValueOf(obj).Elem().FieldByName("Status") - if statusValue.Kind() == reflect.Ptr { - statusValue = statusValue.Elem() - } - if !statusValue.IsValid() || !statusValue.CanAddr() { - return nil - } - return statusValue.Addr().Interface() -} - -// syncLastTransitionTime restores a condition's LastTransitionTime value for -// each proposed condition that is otherwise equivalent to the original value. -// This method is useful to prevent updating the status for a resource that is -// otherwise unchanged. -func (r *ResourceReconciler[T]) syncLastTransitionTime(proposed, original []metav1.Condition) { - for _, o := range original { - for i := range proposed { - p := &proposed[i] - if o.Type == p.Type { - if o.Status == p.Status && - o.Reason == p.Reason && - o.Message == p.Message && - o.ObservedGeneration == p.ObservedGeneration { - p.LastTransitionTime = o.LastTransitionTime - } - break - } - } - } +// SubReconciler are participants in a larger reconciler request. The resource +// being reconciled is passed directly to the sub reconciler. +type SubReconciler[Type client.Object] interface { + SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error + Reconcile(ctx context.Context, resource Type) (Result, error) } -// AggregateReconciler is a controller-runtime reconciler that reconciles a specific resource. The -// Type resource is fetched for the reconciler -// request and passed in turn to each SubReconciler. Finally, the reconciled -// resource's status is compared with the original status, updating the API -// server if needed. -type AggregateReconciler[Type client.Object] struct { - // Name used to identify this reconciler. Defaults to `{Type}ResourceReconciler`. Ideally - // unique, but not required to be so. - // - // +optional - Name string - - // Setup performs initialization on the manager and builder this reconciler - // will run with. It's common to setup field indexes and watch resources. - // - // +optional - Setup func(ctx context.Context, mgr Manager, bldr *Builder) error - - // Type of resource to reconcile. Required when the generic type is not a - // struct, or is unstructured. - // - // +optional - Type Type - - // Request of resource to reconcile. Only the specific resource matching the namespace and name - // is reconciled. The namespace may be empty for cluster scoped resources. - Request Request - - // Reconciler is called for each reconciler request with the resource being reconciled. - // Typically, Reconciler is a Sequence of multiple SubReconcilers. - // - // When HaltSubReconcilers is returned as an error, execution continues as if no error was - // returned. - // - // +optional - Reconciler SubReconciler[Type] - - // DesiredResource returns the desired resource to create/update, or nil if - // the resource should not exist. - // - // +optional - DesiredResource func(ctx context.Context, resource Type) (Type, error) - - // HarmonizeImmutableFields allows fields that are immutable on the current - // object to be copied to the desired object in order to avoid creating - // updates which are guaranteed to fail. - // - // +optional - HarmonizeImmutableFields func(current, desired Type) - - // MergeBeforeUpdate copies desired fields on to the current object before - // calling update. Typically fields to copy are the Spec, Labels and - // Annotations. - MergeBeforeUpdate func(current, desired Type) - - // Sanitize is called with an object before logging the value. Any value may - // be returned. A meaningful subset of the resource is typically returned, - // like the Spec. +var ( + // HaltSubReconcilers is an error that instructs SubReconcilers to stop processing the request, + // while the root reconciler proceeds as if there was no error. HaltSubReconcilers may be + // wrapped by other errors. // - // +optional - Sanitize func(resource Type) interface{} - - Config Config - - // stamp manages the lifecycle of the aggregated resource. - stamp *ResourceManager[Type] - lazyInit sync.Once -} - -func (r *AggregateReconciler[T]) init() { - r.lazyInit.Do(func() { - if internal.IsNil(r.Type) { - var nilT T - r.Type = newEmpty(nilT).(T) - } - if r.Name == "" { - r.Name = fmt.Sprintf("%sAggregateReconciler", typeName(r.Type)) - } - if r.Reconciler == nil { - r.Reconciler = Sequence[T]{} - } - if r.DesiredResource == nil { - r.DesiredResource = func(ctx context.Context, resource T) (T, error) { - return resource, nil - } - } - - r.stamp = &ResourceManager[T]{ - Name: r.Name, - Type: r.Type, - - HarmonizeImmutableFields: r.HarmonizeImmutableFields, - MergeBeforeUpdate: r.MergeBeforeUpdate, - Sanitize: r.Sanitize, - } - }) -} - -func (r *AggregateReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { - _, err := r.SetupWithManagerYieldingController(ctx, mgr) - return err -} - -func (r *AggregateReconciler[T]) SetupWithManagerYieldingController(ctx context.Context, mgr ctrl.Manager) (controller.Controller, error) { - r.init() - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name). - WithValues( - "resourceType", gvk(r.Type, r.Config.Scheme()), - "request", r.Request, - ) - ctx = logr.NewContext(ctx, log) - - ctx = StashConfig(ctx, r.Config) - ctx = StashOriginalConfig(ctx, r.Config) - ctx = StashResourceType(ctx, r.Type) - ctx = StashOriginalResourceType(ctx, r.Type) - - if err := r.validate(ctx); err != nil { - return nil, err - } - - bldr := ctrl.NewControllerManagedBy(mgr).For(r.Type) - if r.Setup != nil { - if err := r.Setup(ctx, mgr, bldr); err != nil { - return nil, err - } - } - if err := r.Reconciler.SetupWithManager(ctx, mgr, bldr); err != nil { - return nil, err - } - if err := r.stamp.Setup(ctx); err != nil { - return nil, err - } - return bldr.Build(r) -} - -func (r *AggregateReconciler[T]) validate(ctx context.Context) error { - // validate Request value - if r.Request.Name == "" { - return fmt.Errorf("AggregateReconciler %q must define Request", r.Name) - } - - // validate Reconciler value - if r.Reconciler == nil && r.DesiredResource == nil { - return fmt.Errorf("AggregateReconciler %q must define Reconciler and/or DesiredResource", r.Name) - } - - return nil -} - -func (r *AggregateReconciler[T]) Reconcile(ctx context.Context, req Request) (Result, error) { - r.init() - - if req.Namespace != r.Request.Namespace || req.Name != r.Request.Name { - // ignore other requests - return Result{}, nil - } - - ctx = WithStash(ctx) - - c := r.Config - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name). - WithValues("resourceType", gvk(r.Type, c.Scheme())) - ctx = logr.NewContext(ctx, log) - - ctx = StashRequest(ctx, req) - ctx = StashConfig(ctx, c) - ctx = StashOriginalConfig(ctx, r.Config) - ctx = StashOriginalResourceType(ctx, r.Type) - ctx = StashResourceType(ctx, r.Type) - - resource := r.Type.DeepCopyObject().(T) - if err := c.Get(ctx, req.NamespacedName, resource); err != nil { - if apierrs.IsNotFound(err) { - // not found is ok - resource.SetNamespace(r.Request.Namespace) - resource.SetName(r.Request.Name) - } else { - log.Error(err, "unable to fetch resource") - return Result{}, err - } - } - - if resource.GetDeletionTimestamp() != nil { - // resource is being deleted, nothing to do - return Result{}, nil - } - - result, err := r.Reconciler.Reconcile(ctx, resource) - if err != nil && !errors.Is(err, HaltSubReconcilers) { - return result, err - } - - // hack, ignore track requests from the child reconciler, we have it covered - ctx = StashConfig(ctx, Config{ - Client: c.Client, - APIReader: c.APIReader, - Recorder: c.Recorder, - Tracker: tracker.New(c.Scheme(), 0), - }) - desired, err := r.desiredResource(ctx, resource) - if err != nil { - return Result{}, err - } - _, err = r.stamp.Manage(ctx, resource, resource, desired) - if err != nil { - return Result{}, err - } - return result, nil -} - -func (r *AggregateReconciler[T]) desiredResource(ctx context.Context, resource T) (T, error) { - var nilT T - - if resource.GetDeletionTimestamp() != nil { - // the reconciled resource is pending deletion, cleanup the child resource - return nilT, nil - } - - fn := reflect.ValueOf(r.DesiredResource) - out := fn.Call([]reflect.Value{ - reflect.ValueOf(ctx), - reflect.ValueOf(resource.DeepCopyObject()), - }) - var obj T - if !out[0].IsNil() { - obj = out[0].Interface().(T) - } - var err error - if !out[1].IsNil() { - err = out[1].Interface().(error) - } - return obj, err -} + // See documentation for the specific SubReconciler caller to see how they handle this case. + HaltSubReconcilers = errors.New("stop processing SubReconcilers, without returning an error") +) const requestStashKey StashKey = "reconciler-runtime:request" const configStashKey StashKey = "reconciler-runtime:config" @@ -781,1122 +143,36 @@ func RetrieveAdditionalConfigs(ctx context.Context) map[string]Config { return map[string]Config{} } -// SubReconciler are participants in a larger reconciler request. The resource -// being reconciled is passed directly to the sub reconciler. -type SubReconciler[Type client.Object] interface { - SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error - Reconcile(ctx context.Context, resource Type) (Result, error) -} - -var ( - _ SubReconciler[client.Object] = (*SyncReconciler[client.Object])(nil) - _ SubReconciler[client.Object] = (*ChildReconciler[client.Object, client.Object, client.ObjectList])(nil) - _ SubReconciler[client.Object] = (Sequence[client.Object])(nil) - _ SubReconciler[client.Object] = (*CastResource[client.Object, client.Object])(nil) - _ SubReconciler[client.Object] = (*WithConfig[client.Object])(nil) - _ SubReconciler[client.Object] = (*WithFinalizer[client.Object])(nil) -) +func typeName(i interface{}) string { + if obj, ok := i.(client.Object); ok { + kind := obj.GetObjectKind().GroupVersionKind().Kind + if kind != "" { + return kind + } + } -var ( - // HaltSubReconcilers is an error that instructs SubReconcilers to stop processing the request, - // while the root reconciler proceeds as if there was no error. HaltSubReconcilers may be - // wrapped by other errors. - // - // See documentation for the specific SubReconciler caller to see how they handle this case. - HaltSubReconcilers = errors.New("stop processing SubReconcilers, without returning an error") -) + t := reflect.TypeOf(i) + // TODO do we need this? + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + return t.Name() +} -// SyncReconciler is a sub reconciler for custom reconciliation logic. No -// behavior is defined directly. -type SyncReconciler[Type client.Object] struct { - // Name used to identify this reconciler. Defaults to `SyncReconciler`. Ideally unique, but - // not required to be so. - // - // +optional - Name string +func gvk(obj client.Object, scheme *runtime.Scheme) schema.GroupVersionKind { + gvks, _, err := scheme.ObjectKinds(obj) + if err != nil { + return schema.GroupVersionKind{} + } + return gvks[0] +} - // Setup performs initialization on the manager and builder this reconciler - // will run with. It's common to setup field indexes and watch resources. - // - // +optional - Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error - - // SyncDuringFinalization indicates the Sync method should be called when the resource is pending deletion. - SyncDuringFinalization bool - - // Sync does whatever work is necessary for the reconciler. - // - // If SyncDuringFinalization is true this method is called when the resource is pending - // deletion. This is useful if the reconciler is managing reference data. - // - // Mutually exclusive with SyncWithResult - Sync func(ctx context.Context, resource Type) error - - // SyncWithResult does whatever work is necessary for the reconciler. - // - // If SyncDuringFinalization is true this method is called when the resource is pending - // deletion. This is useful if the reconciler is managing reference data. - // - // Mutually exclusive with Sync - SyncWithResult func(ctx context.Context, resource Type) (Result, error) - - // Finalize does whatever work is necessary for the reconciler when the resource is pending - // deletion. If this reconciler sets a finalizer it should do the necessary work to clean up - // state the finalizer represents and then clear the finalizer. - // - // Mutually exclusive with FinalizeWithResult - // - // +optional - Finalize func(ctx context.Context, resource Type) error - - // Finalize does whatever work is necessary for the reconciler when the resource is pending - // deletion. If this reconciler sets a finalizer it should do the necessary work to clean up - // state the finalizer represents and then clear the finalizer. - // - // Mutually exclusive with Finalize - // - // +optional - FinalizeWithResult func(ctx context.Context, resource Type) (Result, error) -} - -func (r *SyncReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { - if r.Name == "" { - r.Name = "SyncReconciler" - } - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name) - ctx = logr.NewContext(ctx, log) - - if r.Setup == nil { - return nil - } - if err := r.validate(ctx); err != nil { - return err - } - return r.Setup(ctx, mgr, bldr) -} - -func (r *SyncReconciler[T]) validate(ctx context.Context) error { - // validate Sync and SyncWithResult - if r.Sync == nil && r.SyncWithResult == nil { - return fmt.Errorf("SyncReconciler %q must implement Sync or SyncWithResult", r.Name) - } - if r.Sync != nil && r.SyncWithResult != nil { - return fmt.Errorf("SyncReconciler %q may not implement both Sync and SyncWithResult", r.Name) - } - - // validate Finalize and FinalizeWithResult - if r.Finalize != nil && r.FinalizeWithResult != nil { - return fmt.Errorf("SyncReconciler %q may not implement both Finalize and FinalizeWithResult", r.Name) - } - - return nil -} - -func (r *SyncReconciler[T]) Reconcile(ctx context.Context, resource T) (Result, error) { - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name) - ctx = logr.NewContext(ctx, log) - - result := Result{} - - if resource.GetDeletionTimestamp() == nil || r.SyncDuringFinalization { - syncResult, err := r.sync(ctx, resource) - result = AggregateResults(result, syncResult) - if err != nil { - log.Error(err, "unable to sync") - return result, err - } - } - - if resource.GetDeletionTimestamp() != nil { - finalizeResult, err := r.finalize(ctx, resource) - result = AggregateResults(result, finalizeResult) - if err != nil { - log.Error(err, "unable to finalize") - return result, err - } - } - - return result, nil -} - -func (r *SyncReconciler[T]) sync(ctx context.Context, resource T) (Result, error) { - if r.Sync != nil { - err := r.Sync(ctx, resource) - return Result{}, err - } - return r.SyncWithResult(ctx, resource) -} - -func (r *SyncReconciler[T]) finalize(ctx context.Context, resource T) (Result, error) { - if r.Finalize != nil { - err := r.Finalize(ctx, resource) - return Result{}, err - } - if r.FinalizeWithResult != nil { - return r.FinalizeWithResult(ctx, resource) - } - - return Result{}, nil -} - -var ( - OnlyReconcileChildStatus = errors.New("skip reconciler create/update/delete behavior for the child resource, while still reflecting the existing child's status on the reconciled resource") -) - -// ChildReconciler is a sub reconciler that manages a single child resource for a reconciled -// resource. The reconciler will ensure that exactly one child will match the desired state by: -// - creating a child if none exists -// - updating an existing child -// - removing an unneeded child -// - removing extra children -// -// The flow for each reconciliation request is: -// - DesiredChild -// - if child is desired, HarmonizeImmutableFields (optional) -// - if child is desired, MergeBeforeUpdate -// - ReflectChildStatusOnParent -// -// During setup, the child resource type is registered to watch for changes. -type ChildReconciler[Type, ChildType client.Object, ChildListType client.ObjectList] struct { - // Name used to identify this reconciler. Defaults to `{ChildType}ChildReconciler`. Ideally - // unique, but not required to be so. - // - // +optional - Name string - - // ChildType is the resource being created/updated/deleted by the reconciler. For example, a - // reconciled resource Deployment would have a ReplicaSet as a child. Required when the - // generic type is not a struct, or is unstructured. - // - // +optional - ChildType ChildType - // ChildListType is the listing type for the child type. For example, - // PodList is the list type for Pod. Required when the generic type is not - // a struct, or is unstructured. - // - // +optional - ChildListType ChildListType - - // Finalizer is set on the reconciled resource before a child resource is created, and cleared - // after a child resource is deleted. The value must be unique to this specific reconciler - // instance and not shared. Reusing a value may result in orphaned resources when the - // reconciled resource is deleted. - // - // Using a finalizer is encouraged when the Kubernetes garbage collector is unable to delete - // the child resource automatically, like when the reconciled resource and child are in different - // namespaces, scopes or clusters. - // - // Use of a finalizer implies that SkipOwnerReference is true, and OurChild must be defined. - // - // +optional - Finalizer string - - // SkipOwnerReference when true will not create and find child resources via an owner - // reference. OurChild must be defined for the reconciler to distinguish the child being - // reconciled from other resources of the same type. - // - // Any child resource created is tracked for changes. - SkipOwnerReference bool - - // Setup performs initialization on the manager and builder this reconciler - // will run with. It's common to setup field indexes and watch resources. - // - // +optional - Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error - - // DesiredChild returns the desired child object for the given reconciled resource, or nil if - // the child should not exist. - // - // To skip reconciliation of the child resource while still reflecting an existing child's - // status on the reconciled resource, return OnlyReconcileChildStatus as an error. - DesiredChild func(ctx context.Context, resource Type) (ChildType, error) - - // ReflectChildStatusOnParent updates the reconciled resource's status with values from the - // child. Select types of errors are passed, including: - // - apierrs.IsAlreadyExists - // - // Most errors are returned directly, skipping this method. The set of handled error types - // may grow, implementations should be defensive rather than assuming the error type. - ReflectChildStatusOnParent func(ctx context.Context, parent Type, child ChildType, err error) - - // HarmonizeImmutableFields allows fields that are immutable on the current - // object to be copied to the desired object in order to avoid creating - // updates which are guaranteed to fail. - // - // +optional - HarmonizeImmutableFields func(current, desired ChildType) - - // MergeBeforeUpdate copies desired fields on to the current object before - // calling update. Typically fields to copy are the Spec, Labels and - // Annotations. - MergeBeforeUpdate func(current, desired ChildType) - - // ListOptions allows custom options to be use when listing potential child resources. Each - // resource retrieved as part of the listing is confirmed via OurChild. - // - // Defaults to filtering by the reconciled resource's namespace: - // []client.ListOption{ - // client.InNamespace(resource.GetNamespace()), - // } - // - // +optional - ListOptions func(ctx context.Context, resource Type) []client.ListOption - - // OurChild is used when there are multiple ChildReconciler for the same ChildType controlled - // by the same reconciled resource. The function return true for child resources managed by - // this ChildReconciler. Objects returned from the DesiredChild function should match this - // function, otherwise they may be orphaned. If not specified, all children match. - // - // OurChild is required when a Finalizer is defined or SkipOwnerReference is true. - // - // +optional - OurChild func(resource Type, child ChildType) bool - - // Sanitize is called with an object before logging the value. Any value may - // be returned. A meaningful subset of the resource is typically returned, - // like the Spec. - // - // +optional - Sanitize func(child ChildType) interface{} - - stamp *ResourceManager[ChildType] - lazyInit sync.Once -} - -func (r *ChildReconciler[T, CT, CLT]) init() { - r.lazyInit.Do(func() { - if internal.IsNil(r.ChildType) { - var nilCT CT - r.ChildType = newEmpty(nilCT).(CT) - } - if internal.IsNil(r.ChildListType) { - var nilCLT CLT - r.ChildListType = newEmpty(nilCLT).(CLT) - } - if r.Name == "" { - r.Name = fmt.Sprintf("%sChildReconciler", typeName(r.ChildType)) - } - if r.stamp == nil { - r.stamp = &ResourceManager[CT]{ - Name: r.Name, - Type: r.ChildType, - Finalizer: r.Finalizer, - TrackDesired: r.SkipOwnerReference, - HarmonizeImmutableFields: r.HarmonizeImmutableFields, - MergeBeforeUpdate: r.MergeBeforeUpdate, - Sanitize: r.Sanitize, - } - } - }) -} - -func (r *ChildReconciler[T, CT, CLT]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { - r.init() - - c := RetrieveConfigOrDie(ctx) - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name). - WithValues("childType", gvk(r.ChildType, c.Scheme())) - ctx = logr.NewContext(ctx, log) - - if err := r.validate(ctx); err != nil { - return err - } - - if r.SkipOwnerReference { - bldr.Watches(r.ChildType, EnqueueTracked(ctx)) - } else { - bldr.Owns(r.ChildType) - } - - if r.Setup == nil { - return nil - } - return r.Setup(ctx, mgr, bldr) -} - -func (r *ChildReconciler[T, CT, CLT]) validate(ctx context.Context) error { - // default implicit values - if r.Finalizer != "" { - r.SkipOwnerReference = true - } - - // require DesiredChild - if r.DesiredChild == nil { - return fmt.Errorf("ChildReconciler %q must implement DesiredChild", r.Name) - } - - // require ReflectChildStatusOnParent - if r.ReflectChildStatusOnParent == nil { - return fmt.Errorf("ChildReconciler %q must implement ReflectChildStatusOnParent", r.Name) - } - - if r.OurChild == nil && r.SkipOwnerReference { - // OurChild is required when SkipOwnerReference is true - return fmt.Errorf("ChildReconciler %q must implement OurChild since owner references are not used", r.Name) - } - - return nil -} - -func (r *ChildReconciler[T, CT, CLT]) SetResourceManager(rm *ResourceManager[CT]) { - if r.stamp != nil { - panic(fmt.Errorf("cannot call SetResourceManager after a resource manager is defined")) - } - r.stamp = rm -} - -func (r *ChildReconciler[T, CT, CLT]) Reconcile(ctx context.Context, resource T) (Result, error) { - r.init() - - c := RetrieveConfigOrDie(ctx) - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name). - WithValues("childType", gvk(r.ChildType, c.Scheme())) - ctx = logr.NewContext(ctx, log) - - child, err := r.reconcile(ctx, resource) - if resource.GetDeletionTimestamp() != nil { - return Result{}, err - } - if err != nil { - if apierrs.IsAlreadyExists(err) { - // check if the resource blocking create is owned by the reconciled resource. - // the created child from a previous turn may be slow to appear in the informer cache, but shouldn't appear - // on the reconciled resource as being not ready. - apierr := err.(apierrs.APIStatus) - conflicted := r.ChildType.DeepCopyObject().(CT) - _ = c.APIReader.Get(ctx, types.NamespacedName{Namespace: resource.GetNamespace(), Name: apierr.Status().Details.Name}, conflicted) - if r.ourChild(resource, conflicted) { - // skip updating the reconciled resource's status, fail and try again - return Result{}, err - } - log.Info("unable to reconcile child, not owned", "child", namespaceName(conflicted), "ownerRefs", conflicted.GetOwnerReferences()) - r.ReflectChildStatusOnParent(ctx, resource, child, err) - return Result{}, nil - } - log.Error(err, "unable to reconcile child") - return Result{}, err - } - r.ReflectChildStatusOnParent(ctx, resource, child, err) - - return Result{}, nil -} - -func (r *ChildReconciler[T, CT, CLT]) reconcile(ctx context.Context, resource T) (CT, error) { - var nilCT CT - log := logr.FromContextOrDiscard(ctx) - pc := RetrieveOriginalConfigOrDie(ctx) - c := RetrieveConfigOrDie(ctx) - - actual := r.ChildType.DeepCopyObject().(CT) - children := r.ChildListType.DeepCopyObject().(CLT) - if err := c.List(ctx, children, r.listOptions(ctx, resource)...); err != nil { - return nilCT, err - } - items := r.filterChildren(resource, children) - if len(items) == 1 { - actual = items[0] - } else if len(items) > 1 { - // this shouldn't happen, delete everything to a clean slate - for _, extra := range items { - log.Info("deleting extra child", "child", namespaceName(extra)) - if err := c.Delete(ctx, extra); err != nil { - pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "DeleteFailed", - "Failed to delete %s %q: %v", typeName(r.ChildType), extra.GetName(), err) - return nilCT, err - } - pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Deleted", - "Deleted %s %q", typeName(r.ChildType), extra.GetName()) - } - } - - desired, err := r.desiredChild(ctx, resource) - if err != nil { - if errors.Is(err, OnlyReconcileChildStatus) { - return actual, nil - } - return nilCT, err - } - if !internal.IsNil(desired) { - if !r.SkipOwnerReference && metav1.GetControllerOfNoCopy(desired) == nil { - if err := ctrl.SetControllerReference(resource, desired, c.Scheme()); err != nil { - return nilCT, err - } - } - if !r.ourChild(resource, desired) { - log.Info("object returned from DesiredChild does not match OurChild, this can result in orphaned children", "child", namespaceName(desired)) - } - } - - // create/update/delete desired child - return r.stamp.Manage(ctx, resource, actual, desired) -} - -func (r *ChildReconciler[T, CT, CLT]) desiredChild(ctx context.Context, resource T) (CT, error) { - var nilCT CT - - if resource.GetDeletionTimestamp() != nil { - // the reconciled resource is pending deletion, cleanup the child resource - return nilCT, nil - } - - return r.DesiredChild(ctx, resource) -} - -func (r *ChildReconciler[T, CT, CLT]) filterChildren(resource T, children CLT) []CT { - items := []CT{} - for _, child := range extractItems[CT](children) { - if r.ourChild(resource, child) { - items = append(items, child) - } - } - return items -} - -func (r *ChildReconciler[T, CT, CLT]) listOptions(ctx context.Context, resource T) []client.ListOption { - if r.ListOptions == nil { - return []client.ListOption{ - client.InNamespace(resource.GetNamespace()), - } - } - return r.ListOptions(ctx, resource) -} - -func (r *ChildReconciler[T, CT, CLT]) ourChild(resource T, obj CT) bool { - if !r.SkipOwnerReference && !metav1.IsControlledBy(obj, resource) { - return false - } - // TODO do we need to remove resources pending deletion? - if r.OurChild == nil { - return true - } - return r.OurChild(resource, obj) -} - -// Sequence is a collection of SubReconcilers called in order. If a -// reconciler errs, further reconcilers are skipped. -type Sequence[Type client.Object] []SubReconciler[Type] - -func (r Sequence[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { - for i, reconciler := range r { - log := logr.FromContextOrDiscard(ctx). - WithName(fmt.Sprintf("%d", i)) - ctx = logr.NewContext(ctx, log) - - err := reconciler.SetupWithManager(ctx, mgr, bldr) - if err != nil { - return err - } - } - return nil -} - -func (r Sequence[T]) Reconcile(ctx context.Context, resource T) (Result, error) { - aggregateResult := Result{} - for i, reconciler := range r { - log := logr.FromContextOrDiscard(ctx). - WithName(fmt.Sprintf("%d", i)) - ctx = logr.NewContext(ctx, log) - - result, err := reconciler.Reconcile(ctx, resource) - aggregateResult = AggregateResults(result, aggregateResult) - if err != nil { - return result, err - } - } - - return aggregateResult, nil -} - -// CastResource casts the ResourceReconciler's type by projecting the resource data -// onto a new struct. Casting the reconciled resource is useful to create cross -// cutting reconcilers that can operate on common portion of multiple resources, -// commonly referred to as a duck type. -// -// If the CastType generic is an interface rather than a struct, the resource is -// passed directly rather than converted. -type CastResource[Type, CastType client.Object] struct { - // Name used to identify this reconciler. Defaults to `{Type}CastResource`. Ideally unique, but - // not required to be so. - // - // +optional - Name string - - // Reconciler is called for each reconciler request with the reconciled resource. Typically a - // Sequence is used to compose multiple SubReconcilers. - Reconciler SubReconciler[CastType] - - noop bool - lazyInit sync.Once -} - -func (r *CastResource[T, CT]) init() { - r.lazyInit.Do(func() { - var nilCT CT - if reflect.ValueOf(nilCT).Kind() == reflect.Invalid { - // not a real cast, just converting generic types - r.noop = true - return - } - emptyCT := newEmpty(nilCT) - if r.Name == "" { - r.Name = fmt.Sprintf("%sCastResource", typeName(emptyCT)) - } - }) -} - -func (r *CastResource[T, CT]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { - r.init() - - if !r.noop { - var nilCT CT - emptyCT := newEmpty(nilCT).(CT) - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name). - WithValues("castResourceType", typeName(emptyCT)) - ctx = logr.NewContext(ctx, log) - - if err := r.validate(ctx); err != nil { - return err - } - } - - return r.Reconciler.SetupWithManager(ctx, mgr, bldr) -} - -func (r *CastResource[T, CT]) validate(ctx context.Context) error { - // validate Reconciler value - if r.Reconciler == nil { - return fmt.Errorf("CastResource %q must define Reconciler", r.Name) - } - - return nil -} - -func (r *CastResource[T, CT]) Reconcile(ctx context.Context, resource T) (Result, error) { - r.init() - - if r.noop { - // cast the type rather than convert the object - return r.Reconciler.Reconcile(ctx, client.Object(resource).(CT)) - } - - var nilCT CT - emptyCT := newEmpty(nilCT).(CT) - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name). - WithValues("castResourceType", typeName(emptyCT)) - ctx = logr.NewContext(ctx, log) - - ctx, castResource, err := r.cast(ctx, resource) - if err != nil { - return Result{}, err - } - castOriginal := castResource.DeepCopyObject().(client.Object) - result, err := r.Reconciler.Reconcile(ctx, castResource) - if err != nil { - return result, err - } - if !equality.Semantic.DeepEqual(castResource, castOriginal) { - // patch the reconciled resource with the updated duck values - patch, err := NewPatch(castOriginal, castResource) - if err != nil { - return Result{}, err - } - err = patch.Apply(resource) - if err != nil { - return Result{}, err - } - - } - return result, nil -} - -func (r *CastResource[T, CT]) cast(ctx context.Context, resource T) (context.Context, CT, error) { - var nilCT CT - - data, err := json.Marshal(resource) - if err != nil { - return nil, nilCT, err - } - castResource := newEmpty(nilCT).(CT) - err = json.Unmarshal(data, castResource) - if err != nil { - return nil, nilCT, err - } - if kind := castResource.GetObjectKind(); kind.GroupVersionKind().Empty() { - // default the apiVersion/kind with the real value from the resource if not already defined - c := RetrieveConfigOrDie(ctx) - kind.SetGroupVersionKind(gvk(resource, c.Scheme())) - } - ctx = StashResourceType(ctx, castResource) - return ctx, castResource, nil -} - -// WithConfig injects the provided config into the reconcilers nested under it. For example, the -// client can be swapped to use a service account with different permissions, or to target an -// entirely different cluster. -// -// The specified config can be accessed with `RetrieveConfig(ctx)`, the original config used to -// load the reconciled resource can be accessed with `RetrieveOriginalConfig(ctx)`. -type WithConfig[Type client.Object] struct { - // Name used to identify this reconciler. Defaults to `WithConfig`. Ideally unique, but - // not required to be so. - // - // +optional - Name string - - // Config to use for this portion of the reconciler hierarchy. This method is called during - // setup and during reconciliation, if context is needed, it should be available durring both - // phases. - Config func(context.Context, Config) (Config, error) - - // Reconciler is called for each reconciler request with the reconciled - // resource being reconciled. Typically a Sequence is used to compose - // multiple SubReconcilers. - Reconciler SubReconciler[Type] -} - -func (r *WithConfig[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { - if r.Name == "" { - r.Name = "WithConfig" - } - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name) - ctx = logr.NewContext(ctx, log) - - if err := r.validate(ctx); err != nil { - return err - } - c, err := r.Config(ctx, RetrieveConfigOrDie(ctx)) - if err != nil { - return err - } - ctx = StashConfig(ctx, c) - return r.Reconciler.SetupWithManager(ctx, mgr, bldr) -} - -func (r *WithConfig[T]) validate(ctx context.Context) error { - // validate Config value - if r.Config == nil { - return fmt.Errorf("WithConfig %q must define Config", r.Name) - } - - // validate Reconciler value - if r.Reconciler == nil { - return fmt.Errorf("WithConfig %q must define Reconciler", r.Name) - } - - return nil -} - -func (r *WithConfig[T]) Reconcile(ctx context.Context, resource T) (Result, error) { - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name) - ctx = logr.NewContext(ctx, log) - - c, err := r.Config(ctx, RetrieveConfigOrDie(ctx)) - if err != nil { - return Result{}, err - } - ctx = StashConfig(ctx, c) - return r.Reconciler.Reconcile(ctx, resource) -} - -// WithFinalizer ensures the resource being reconciled has the desired finalizer set so that state -// can be cleaned up upon the resource being deleted. The finalizer is added to the resource, if not -// already set, before calling the nested reconciler. When the resource is terminating, the -// finalizer is cleared after returning from the nested reconciler without error. -type WithFinalizer[Type client.Object] struct { - // Name used to identify this reconciler. Defaults to `WithFinalizer`. Ideally unique, but - // not required to be so. - // - // +optional - Name string - - // Finalizer to set on the reconciled resource. The value must be unique to this specific - // reconciler instance and not shared. Reusing a value may result in orphaned state when - // the reconciled resource is deleted. - // - // Using a finalizer is encouraged when state needs to be manually cleaned up before a resource - // is fully deleted. This commonly include state allocated outside of the current cluster. - Finalizer string - - // Reconciler is called for each reconciler request with the reconciled - // resource being reconciled. Typically a Sequence is used to compose - // multiple SubReconcilers. - Reconciler SubReconciler[Type] -} - -func (r *WithFinalizer[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { - if r.Name == "" { - r.Name = "WithFinalizer" - } - - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name) - ctx = logr.NewContext(ctx, log) - - if err := r.validate(ctx); err != nil { - return err - } - return r.Reconciler.SetupWithManager(ctx, mgr, bldr) -} - -func (r *WithFinalizer[T]) validate(ctx context.Context) error { - // validate Finalizer value - if r.Finalizer == "" { - return fmt.Errorf("WithFinalizer %q must define Finalizer", r.Name) - } - - // validate Reconciler value - if r.Reconciler == nil { - return fmt.Errorf("WithFinalizer %q must define Reconciler", r.Name) - } - - return nil -} - -func (r *WithFinalizer[T]) Reconcile(ctx context.Context, resource T) (Result, error) { - log := logr.FromContextOrDiscard(ctx). - WithName(r.Name) - ctx = logr.NewContext(ctx, log) - - if resource.GetDeletionTimestamp() == nil { - if err := AddFinalizer(ctx, resource, r.Finalizer); err != nil { - return Result{}, err - } - } - result, err := r.Reconciler.Reconcile(ctx, resource) - if err != nil { - return result, err - } - if resource.GetDeletionTimestamp() != nil { - if err := ClearFinalizer(ctx, resource, r.Finalizer); err != nil { - return Result{}, err - } - } - return result, err -} - -// ResourceManager compares the actual and desired resources to create/update/delete as desired. -type ResourceManager[Type client.Object] struct { - // Name used to identify this reconciler. Defaults to `{Type}ResourceManager`. Ideally - // unique, but not required to be so. - // - // +optional - Name string - - // Type is the resource being created/updated/deleted by the reconciler. Required when the - // generic type is not a struct, or is unstructured. - // - // +optional - Type Type - - // Finalizer is set on the reconciled resource before a managed resource is created, and cleared - // after a managed resource is deleted. The value must be unique to this specific manager - // instance and not shared. Reusing a value may result in orphaned resources when the - // reconciled resource is deleted. - // - // Using a finalizer is encouraged when the Kubernetes garbage collector is unable to delete - // the child resource automatically, like when the reconciled resource and child are in different - // namespaces, scopes or clusters. - // - // +optional - Finalizer string - - // TrackDesired when true, the desired resource is tracked after creates, before - // updates, and on delete errors. - TrackDesired bool - - // HarmonizeImmutableFields allows fields that are immutable on the current - // object to be copied to the desired object in order to avoid creating - // updates which are guaranteed to fail. - // - // +optional - HarmonizeImmutableFields func(current, desired Type) - - // MergeBeforeUpdate copies desired fields on to the current object before - // calling update. Typically fields to copy are the Spec, Labels and - // Annotations. - MergeBeforeUpdate func(current, desired Type) - - // Sanitize is called with an object before logging the value. Any value may - // be returned. A meaningful subset of the resource is typically returned, - // like the Spec. - // - // +optional - Sanitize func(child Type) interface{} - - // mutationCache holds patches received from updates to a resource made by - // mutation webhooks. This cache is used to avoid unnecessary update calls - // that would actually have no effect. - mutationCache *cache.Expiring - lazyInit sync.Once -} - -func (r *ResourceManager[T]) init() { - r.lazyInit.Do(func() { - if internal.IsNil(r.Type) { - var nilT T - r.Type = newEmpty(nilT).(T) - } - if r.Name == "" { - r.Name = fmt.Sprintf("%sResourceManager", typeName(r.Type)) - } - r.mutationCache = cache.NewExpiring() - }) -} - -func (r *ResourceManager[T]) Setup(ctx context.Context) error { - r.init() - return r.validate(ctx) -} - -func (r *ResourceManager[T]) validate(ctx context.Context) error { - // require MergeBeforeUpdate - if r.MergeBeforeUpdate == nil { - return fmt.Errorf("ResourceManager %q must define MergeBeforeUpdate", r.Name) - } - - return nil -} - -// Manage a specific resource to create/update/delete based on the actual and desired state. The -// resource is the reconciled resource and used to record events for mutations. The actual and -// desired objects represent the managed resource and must be compatible with the type field. -func (r *ResourceManager[T]) Manage(ctx context.Context, resource client.Object, actual, desired T) (T, error) { - r.init() - - var nilT T - - log := logr.FromContextOrDiscard(ctx) - pc := RetrieveOriginalConfigOrDie(ctx) - c := RetrieveConfigOrDie(ctx) - - if (internal.IsNil(actual) || actual.GetCreationTimestamp().Time.IsZero()) && internal.IsNil(desired) { - if err := ClearFinalizer(ctx, resource, r.Finalizer); err != nil { - return nilT, err - } - return nilT, nil - } - - // delete resource if no longer needed - if internal.IsNil(desired) { - if !actual.GetCreationTimestamp().Time.IsZero() && actual.GetDeletionTimestamp() == nil { - log.Info("deleting unwanted resource", "resource", namespaceName(actual)) - if err := c.Delete(ctx, actual); err != nil { - log.Error(err, "unable to delete unwanted resource", "resource", namespaceName(actual)) - pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "DeleteFailed", - "Failed to delete %s %q: %v", typeName(actual), actual.GetName(), err) - return nilT, err - } - pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Deleted", - "Deleted %s %q", typeName(actual), actual.GetName()) - - } - return nilT, nil - } - - if err := AddFinalizer(ctx, resource, r.Finalizer); err != nil { - return nilT, err - } - - // create resource if it doesn't exist - if internal.IsNil(actual) || actual.GetCreationTimestamp().Time.IsZero() { - log.Info("creating resource", "resource", r.sanitize(desired)) - if err := c.Create(ctx, desired); err != nil { - log.Error(err, "unable to create resource", "resource", namespaceName(desired)) - pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "CreationFailed", - "Failed to create %s %q: %v", typeName(desired), desired.GetName(), err) - return nilT, err - } - if r.TrackDesired { - // normally tracks should occur before API operations, but when creating a resource with a - // generated name, we need to know the actual resource name. - - if err := c.Tracker.TrackObject(desired, resource); err != nil { - return nilT, err - } - } - pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Created", - "Created %s %q", typeName(desired), desired.GetName()) - return desired, nil - } - - // overwrite fields that should not be mutated - if r.HarmonizeImmutableFields != nil { - r.HarmonizeImmutableFields(actual, desired) - } - - // lookup and apply remote mutations - desiredPatched := desired.DeepCopyObject().(T) - if patch, ok := r.mutationCache.Get(actual.GetUID()); ok { - // the only object added to the cache is *Patch - err := patch.(*Patch).Apply(desiredPatched) - if err != nil { - // there's not much we can do, but let the normal update proceed - log.Info("unable to patch desired child from mutation cache") - } - } - - // update resource with desired changes - current := actual.DeepCopyObject().(T) - r.MergeBeforeUpdate(current, desiredPatched) - if equality.Semantic.DeepEqual(current, actual) { - // resource is unchanged - log.Info("resource is in sync, no update required") - return actual, nil - } - log.Info("updating resource", "diff", cmp.Diff(r.sanitize(actual), r.sanitize(current))) - if r.TrackDesired { - if err := c.Tracker.TrackObject(current, resource); err != nil { - return nilT, err - } - } - if err := c.Update(ctx, current); err != nil { - log.Error(err, "unable to update resource", "resource", namespaceName(current)) - pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "UpdateFailed", - "Failed to update %s %q: %v", typeName(current), current.GetName(), err) - return nilT, err - } - - // capture admission mutation patch - base := current.DeepCopyObject().(T) - r.MergeBeforeUpdate(base, desired) - patch, err := NewPatch(base, current) - if err != nil { - log.Error(err, "unable to generate mutation patch", "snapshot", r.sanitize(desired), "base", r.sanitize(base)) - } else { - r.mutationCache.Set(current.GetUID(), patch, 1*time.Hour) - } - - log.Info("updated resource") - pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Updated", - "Updated %s %q", typeName(current), current.GetName()) - - return current, nil -} - -func (r *ResourceManager[T]) sanitize(resource T) interface{} { - if r.Sanitize == nil { - return resource - } - if internal.IsNil(resource) { - return nil - } - - // avoid accidental mutations in Sanitize method - resource = resource.DeepCopyObject().(T) - return r.Sanitize(resource) -} - -func typeName(i interface{}) string { - if obj, ok := i.(client.Object); ok { - kind := obj.GetObjectKind().GroupVersionKind().Kind - if kind != "" { - return kind - } - } - - t := reflect.TypeOf(i) - // TODO do we need this? - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - return t.Name() -} - -func gvk(obj client.Object, scheme *runtime.Scheme) schema.GroupVersionKind { - gvks, _, err := scheme.ObjectKinds(obj) - if err != nil { - return schema.GroupVersionKind{} - } - return gvks[0] -} - -func namespaceName(obj client.Object) types.NamespacedName { - return types.NamespacedName{ - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - } -} - -// AddFinalizer ensures the desired finalizer exists on the reconciled resource. The client that -// loaded the reconciled resource is used to patch it with the finalizer if not already set. -func AddFinalizer(ctx context.Context, resource client.Object, finalizer string) error { - return ensureFinalizer(ctx, resource, finalizer, true) -} - -// ClearFinalizer ensures the desired finalizer does not exist on the reconciled resource. The -// client that loaded the reconciled resource is used to patch it with the finalizer if set. -func ClearFinalizer(ctx context.Context, resource client.Object, finalizer string) error { - return ensureFinalizer(ctx, resource, finalizer, false) -} - -func ensureFinalizer(ctx context.Context, resource client.Object, finalizer string, add bool) error { - config := RetrieveOriginalConfigOrDie(ctx) - if config.IsEmpty() { - panic(fmt.Errorf("resource config must exist on the context. Check that the context from a ResourceReconciler")) - } - resourceType := RetrieveOriginalResourceType(ctx) - if resourceType == nil { - panic(fmt.Errorf("resource type must exist on the context. Check that the context from a ResourceReconciler")) - } - - if finalizer == "" || controllerutil.ContainsFinalizer(resource, finalizer) == add { - // nothing to do - return nil - } - - // cast the current object back to the resource so scheme-aware, typed client can operate on it - cast := &CastResource[client.Object, client.Object]{ - Reconciler: &SyncReconciler[client.Object]{ - SyncDuringFinalization: true, - Sync: func(ctx context.Context, current client.Object) error { - log := logr.FromContextOrDiscard(ctx) - - desired := current.DeepCopyObject().(client.Object) - if add { - log.Info("adding finalizer", "finalizer", finalizer) - controllerutil.AddFinalizer(desired, finalizer) - } else { - log.Info("removing finalizer", "finalizer", finalizer) - controllerutil.RemoveFinalizer(desired, finalizer) - } - - patch := client.MergeFromWithOptions(current, client.MergeFromWithOptimisticLock{}) - if err := config.Patch(ctx, desired, patch); err != nil { - log.Error(err, "unable to patch finalizers", "finalizer", finalizer) - config.Recorder.Eventf(current, corev1.EventTypeWarning, "FinalizerPatchFailed", - "Failed to patch finalizer %q: %s", finalizer, err) - return err - } - config.Recorder.Eventf(current, corev1.EventTypeNormal, "FinalizerPatched", - "Patched finalizer %q", finalizer) - - // update current object with values from the api server after patching - current.SetFinalizers(desired.GetFinalizers()) - current.SetResourceVersion(desired.GetResourceVersion()) - current.SetGeneration(desired.GetGeneration()) - - return nil - }, - }, - } - - _, err := cast.Reconcile(ctx, resource) - return err -} +func namespaceName(obj client.Object) types.NamespacedName { + return types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + } +} // AggregateResults combines multiple results into a single result. If any result requests // requeue, the aggregate is requeued. The shortest non-zero requeue after is the aggregate value. @@ -1925,6 +201,29 @@ func MergeMaps(maps ...map[string]string) map[string]string { return out } +func EnqueueTracked(ctx context.Context) handler.EventHandler { + c := RetrieveConfigOrDie(ctx) + log := logr.FromContextOrDiscard(ctx) + + return handler.EnqueueRequestsFromMapFunc( + func(ctx context.Context, obj client.Object) []Request { + var requests []Request + + items, err := c.Tracker.GetObservers(obj) + if err != nil { + log.Error(err, "unable to get tracked requests") + return nil + } + + for _, item := range items { + requests = append(requests, Request{NamespacedName: item}) + } + + return requests + }, + ) +} + // replaceWithEmpty overwrite the underlying value with it's empty value func replaceWithEmpty(x interface{}) { v := reflect.ValueOf(x).Elem() @@ -1936,15 +235,3 @@ func newEmpty(x interface{}) interface{} { t := reflect.TypeOf(x).Elem() return reflect.New(t).Interface() } - -// extractItems returns a typed slice of objects from an object list -func extractItems[T client.Object](list client.ObjectList) []T { - items := []T{} - listValue := reflect.ValueOf(list).Elem() - itemsValue := listValue.FieldByName("Items") - for i := 0; i < itemsValue.Len(); i++ { - item := itemsValue.Index(i).Addr().Interface().(T) - items = append(items, item) - } - return items -} diff --git a/reconcilers/reconcilers_test.go b/reconcilers/reconcilers_test.go deleted file mode 100644 index 48daada..0000000 --- a/reconcilers/reconcilers_test.go +++ /dev/null @@ -1,4419 +0,0 @@ -/* -Copyright 2020 VMware, Inc. -SPDX-License-Identifier: Apache-2.0 -*/ - -package reconcilers_test - -import ( - "context" - "fmt" - "testing" - "time" - - diecorev1 "dies.dev/apis/core/v1" - diemetav1 "dies.dev/apis/meta/v1" - "github.com/google/go-cmp/cmp" - "github.com/vmware-labs/reconciler-runtime/apis" - "github.com/vmware-labs/reconciler-runtime/internal/resources" - "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" - "github.com/vmware-labs/reconciler-runtime/reconcilers" - rtesting "github.com/vmware-labs/reconciler-runtime/testing" - "github.com/vmware-labs/reconciler-runtime/tracker" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - apierrs "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/apimachinery/pkg/types" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "k8s.io/utils/pointer" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" -) - -func TestConfig_TrackAndGet(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - _ = clientgoscheme.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }) - - configMap := diecorev1.ConfigMapBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace("track-namespace") - d.Name("track-name") - }). - AddData("greeting", "hello") - - rts := rtesting.SubReconcilerTests[*resources.TestResource]{ - "track and get": { - Resource: resource.DieReleasePtr(), - GivenObjects: []client.Object{ - configMap, - }, - ExpectTracks: []rtesting.TrackRequest{ - rtesting.NewTrackRequest(configMap, resource, scheme), - }, - }, - "track with not found get": { - Resource: resource.DieReleasePtr(), - ShouldErr: true, - ExpectTracks: []rtesting.TrackRequest{ - rtesting.NewTrackRequest(configMap, resource, scheme), - }, - }, - } - - // run with typed objects - t.Run("typed", func(t *testing.T) { - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - c := reconcilers.RetrieveConfigOrDie(ctx) - - cm := &corev1.ConfigMap{} - err := c.TrackAndGet(ctx, types.NamespacedName{Namespace: "track-namespace", Name: "track-name"}, cm) - if err != nil { - return err - } - - if expected, actual := "hello", cm.Data["greeting"]; expected != actual { - // should never get here - panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) - } - return nil - }, - } - }) - }) - - // run with unstructured objects - t.Run("unstructured", func(t *testing.T) { - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - c := reconcilers.RetrieveConfigOrDie(ctx) - - cm := &unstructured.Unstructured{} - cm.SetAPIVersion("v1") - cm.SetKind("ConfigMap") - err := c.TrackAndGet(ctx, types.NamespacedName{Namespace: "track-namespace", Name: "track-name"}, cm) - if err != nil { - return err - } - - if expected, actual := "hello", cm.UnstructuredContent()["data"].(map[string]interface{})["greeting"].(string); expected != actual { - // should never get here - panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) - } - return nil - }, - } - }) - }) -} - -func TestConfig_TrackAndList(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - testSelector, _ := labels.Parse("app=test-app") - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - _ = clientgoscheme.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }) - - configMap := diecorev1.ConfigMapBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace("track-namespace") - d.Name("track-name") - d.AddLabel("app", "test-app") - }). - AddData("greeting", "hello") - - rts := rtesting.SubReconcilerTests[*resources.TestResource]{ - "track and list": { - Resource: resource.DieReleasePtr(), - GivenObjects: []client.Object{ - configMap, - }, - Metadata: map[string]interface{}{ - "listOpts": []client.ListOption{}, - }, - ExpectTracks: []rtesting.TrackRequest{ - { - Tracker: types.NamespacedName{ - Namespace: testNamespace, - Name: testName, - }, - TrackedReference: tracker.Reference{ - Kind: "ConfigMap", - Selector: labels.Everything(), - }, - }, - }, - }, - "track and list constrained": { - Resource: resource.DieReleasePtr(), - GivenObjects: []client.Object{ - configMap, - }, - Metadata: map[string]interface{}{ - "listOpts": []client.ListOption{ - client.InNamespace("track-namespace"), - client.MatchingLabels(map[string]string{"app": "test-app"}), - }, - }, - ExpectTracks: []rtesting.TrackRequest{ - { - Tracker: types.NamespacedName{ - Namespace: testNamespace, - Name: testName, - }, - TrackedReference: tracker.Reference{ - Kind: "ConfigMap", - Namespace: "track-namespace", - Selector: testSelector, - }, - }, - }, - }, - "track with errored list": { - Resource: resource.DieReleasePtr(), - ShouldErr: true, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("list", "ConfigMapList"), - }, - Metadata: map[string]interface{}{ - "listOpts": []client.ListOption{}, - }, - ExpectTracks: []rtesting.TrackRequest{ - { - Tracker: types.NamespacedName{ - Namespace: testNamespace, - Name: testName, - }, - TrackedReference: tracker.Reference{ - Kind: "ConfigMap", - Selector: labels.Everything(), - }, - }, - }, - }, - } - - // run with typed objects - t.Run("typed", func(t *testing.T) { - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - c := reconcilers.RetrieveConfigOrDie(ctx) - - cms := &corev1.ConfigMapList{} - listOpts := rtc.Metadata["listOpts"].([]client.ListOption) - err := c.TrackAndList(ctx, cms, listOpts...) - if err != nil { - return err - } - - if expected, actual := "hello", cms.Items[0].Data["greeting"]; expected != actual { - // should never get here - panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) - } - return nil - }, - } - }) - }) - - // run with unstructured objects - t.Run("unstructured", func(t *testing.T) { - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - c := reconcilers.RetrieveConfigOrDie(ctx) - - cms := &unstructured.UnstructuredList{} - cms.SetAPIVersion("v1") - cms.SetKind("ConfigMapList") - listOpts := rtc.Metadata["listOpts"].([]client.ListOption) - err := c.TrackAndList(ctx, cms, listOpts...) - if err != nil { - return err - } - - if expected, actual := "hello", cms.UnstructuredContent()["items"].([]interface{})[0].(map[string]interface{})["data"].(map[string]interface{})["greeting"].(string); expected != actual { - // should never get here - panic(fmt.Errorf("expected configmap to have greeting %q, found %q", expected, actual)) - } - return nil - }, - } - }) - }) -} - -func TestResourceReconciler_NoStatus(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource-no-status" - testRequest := reconcilers.Request{ - NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, - } - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - - resource := dies.TestResourceNoStatusBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - d.AddAnnotation("blah", "blah") - }) - - rts := rtesting.ReconcilerTests{ - "resource exists": { - Request: testRequest, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNoStatus] { - return &reconcilers.SyncReconciler[*resources.TestResourceNoStatus]{ - Sync: func(ctx context.Context, resource *resources.TestResourceNoStatus) error { - return nil - }, - } - }, - }, - }, - } - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { - return &reconcilers.ResourceReconciler[*resources.TestResourceNoStatus]{ - Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNoStatus])(t, c), - Config: c, - } - }) -} - -func TestResourceReconciler_EmptyStatus(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource-empty-status" - testRequest := reconcilers.Request{ - NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, - } - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - - resource := dies.TestResourceEmptyStatusBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - d.AddAnnotation("blah", "blah") - }) - - rts := rtesting.ReconcilerTests{ - "resource exists": { - Request: testRequest, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceEmptyStatus] { - return &reconcilers.SyncReconciler[*resources.TestResourceEmptyStatus]{ - Sync: func(ctx context.Context, resource *resources.TestResourceEmptyStatus) error { - return nil - }, - } - }, - }, - }, - } - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { - return &reconcilers.ResourceReconciler[*resources.TestResourceEmptyStatus]{ - Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceEmptyStatus])(t, c), - Config: c, - } - }) -} - -func TestResourceReconciler_NilableStatus(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - testRequest := reconcilers.Request{ - NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, - } - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - - resource := dies.TestResourceNilableStatusBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), - ) - }) - - rts := rtesting.ReconcilerTests{ - "nil status": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResourceNilableStatus{}, - }, - GivenObjects: []client.Object{ - resource.Status(nil), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus] { - return &reconcilers.SyncReconciler[*resources.TestResourceNilableStatus]{ - Sync: func(ctx context.Context, resource *resources.TestResourceNilableStatus) error { - if resource.Status != nil { - t.Errorf("status expected to be nil") - } - return nil - }, - } - }, - }, - }, - "status conditions are initialized": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResourceNilableStatus{}, - }, - GivenObjects: []client.Object{ - resource.StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus] { - return &reconcilers.SyncReconciler[*resources.TestResourceNilableStatus]{ - Sync: func(ctx context.Context, resource *resources.TestResourceNilableStatus) error { - expected := []metav1.Condition{ - {Type: apis.ConditionReady, Status: metav1.ConditionUnknown, Reason: "Initializing"}, - } - if diff := cmp.Diff(expected, resource.Status.Conditions, rtesting.IgnoreLastTransitionTime); diff != "" { - t.Errorf("Unexpected condition (-expected, +actual): %s", diff) - } - return nil - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", - `Updated status`), - }, - ExpectStatusUpdates: []client.Object{ - resource, - }, - }, - "reconciler mutated status": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResourceNilableStatus{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus] { - return &reconcilers.SyncReconciler[*resources.TestResourceNilableStatus]{ - Sync: func(ctx context.Context, resource *resources.TestResourceNilableStatus) error { - if resource.Status.Fields == nil { - resource.Status.Fields = map[string]string{} - } - resource.Status.Fields["Reconciler"] = "ran" - return nil - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", - `Updated status`), - }, - ExpectStatusUpdates: []client.Object{ - resource.StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("Reconciler", "ran") - }), - }, - }, - "status update failed": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResourceNilableStatus{}, - }, - GivenObjects: []client.Object{ - resource, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("update", "TestResourceNilableStatus", rtesting.InduceFailureOpts{ - SubResource: "status", - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus] { - return &reconcilers.SyncReconciler[*resources.TestResourceNilableStatus]{ - Sync: func(ctx context.Context, resource *resources.TestResourceNilableStatus) error { - if resource.Status.Fields == nil { - resource.Status.Fields = map[string]string{} - } - resource.Status.Fields["Reconciler"] = "ran" - return nil - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "StatusUpdateFailed", - `Failed to update status: inducing failure for update TestResourceNilableStatus`), - }, - ExpectStatusUpdates: []client.Object{ - resource.StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("Reconciler", "ran") - }), - }, - ShouldErr: true, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { - return &reconcilers.ResourceReconciler[*resources.TestResourceNilableStatus]{ - Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus])(t, c), - Config: c, - } - }) -} - -func TestResourceReconciler_Unstructured(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - testRequest := reconcilers.Request{ - NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, - } - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - APIVersion(resources.GroupVersion.Identifier()). - Kind("TestResource"). - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - d.Generation(1) - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), - ) - }) - - rts := rtesting.ReconcilerTests{ - "in sync status": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return &reconcilers.SyncReconciler[*unstructured.Unstructured]{ - Sync: func(ctx context.Context, resource *unstructured.Unstructured) error { - return nil - }, - } - }, - }, - }, - "status update": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return &reconcilers.SyncReconciler[*unstructured.Unstructured]{ - Sync: func(ctx context.Context, resource *unstructured.Unstructured) error { - resource.Object["status"].(map[string]interface{})["fields"] = map[string]interface{}{ - "Reconciler": "ran", - } - return nil - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", `Updated status`), - }, - ExpectStatusUpdates: []client.Object{ - resource.StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("Reconciler", "ran") - }).DieReleaseUnstructured(), - }, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { - return &reconcilers.ResourceReconciler[*unstructured.Unstructured]{ - Type: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": resources.GroupVersion.Identifier(), - "kind": "TestResource", - }, - }, - Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured])(t, c), - Config: c, - } - }) -} - -func TestResourceReconciler(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - testFinalizer := "test.finalizer" - testRequest := reconcilers.Request{ - NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, - } - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), - ) - }) - deletedAt := metav1.NewTime(time.UnixMilli(2000)) - - rts := rtesting.ReconcilerTests{ - "resource does not exist": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - t.Error("should not be called") - return nil - }, - } - }, - }, - }, - "ignore deleted resource": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource.MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&deletedAt) - d.Finalizers(testFinalizer) - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - t.Error("should not be called") - return nil - }, - } - }, - }, - }, - "error fetching resource": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("get", "TestResource"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - t.Error("should not be called") - return nil - }, - } - }, - }, - ShouldErr: true, - }, - "resource is defaulted": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - if expected, actual := "ran", resource.Spec.Fields["Defaulter"]; expected != actual { - t.Errorf("unexpected default value, actually = %v, expected = %v", expected, actual) - } - return nil - }, - } - }, - }, - }, - "status conditions are initialized": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource.StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - expected := []metav1.Condition{ - {Type: apis.ConditionReady, Status: metav1.ConditionUnknown, Reason: "Initializing"}, - } - if diff := cmp.Diff(expected, resource.Status.Conditions, rtesting.IgnoreLastTransitionTime); diff != "" { - t.Errorf("Unexpected condition (-expected, +actual): %s", diff) - } - return nil - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", - `Updated status`), - }, - ExpectStatusUpdates: []client.Object{ - resource, - }, - }, - "reconciler mutated status": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - if resource.Status.Fields == nil { - resource.Status.Fields = map[string]string{} - } - resource.Status.Fields["Reconciler"] = "ran" - return nil - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", - `Updated status`), - }, - ExpectStatusUpdates: []client.Object{ - resource.StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("Reconciler", "ran") - }), - }, - }, - "skip status updates": { - Request: testRequest, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SkipStatusUpdate": true, - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - if resource.Status.Fields == nil { - resource.Status.Fields = map[string]string{} - } - resource.Status.Fields["Reconciler"] = "ran" - return nil - }, - } - }, - }, - }, - "sub reconciler erred": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - return fmt.Errorf("reconciler error") - }, - } - }, - }, - ShouldErr: true, - }, - "sub reconciler halted": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - resource.Status.Fields = map[string]string{ - "want": "this to run", - } - return reconcilers.HaltSubReconcilers - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - resource.Status.Fields = map[string]string{ - "don't want": "this to run", - } - return fmt.Errorf("reconciler error") - }, - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", - `Updated status`), - }, - ExpectStatusUpdates: []client.Object{ - resource.StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("want", "this to run") - }), - }, - }, - "sub reconciler halted with result": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - resource.Status.Fields = map[string]string{ - "want": "this to run", - } - return reconcilers.Result{Requeue: true}, reconcilers.HaltSubReconcilers - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - resource.Status.Fields = map[string]string{ - "don't want": "this to run", - } - return fmt.Errorf("reconciler error") - }, - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", - `Updated status`), - }, - ExpectStatusUpdates: []client.Object{ - resource.StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("want", "this to run") - }), - }, - ExpectedResult: reconcilers.Result{Requeue: true}, - }, - "status update failed": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("update", "TestResource", rtesting.InduceFailureOpts{ - SubResource: "status", - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - if resource.Status.Fields == nil { - resource.Status.Fields = map[string]string{} - } - resource.Status.Fields["Reconciler"] = "ran" - return nil - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "StatusUpdateFailed", - `Failed to update status: inducing failure for update TestResource`), - }, - ExpectStatusUpdates: []client.Object{ - resource.StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("Reconciler", "ran") - }), - }, - ShouldErr: true, - }, - "context is stashable": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - var key reconcilers.StashKey = "foo" - // StashValue will panic if the context is not setup correctly - reconcilers.StashValue(ctx, key, "bar") - return nil - }, - } - }, - }, - }, - "context has config": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - if config := reconcilers.RetrieveConfigOrDie(ctx); config != c { - t.Errorf("expected config in context, found %#v", config) - } - if resourceConfig := reconcilers.RetrieveOriginalConfigOrDie(ctx); resourceConfig != c { - t.Errorf("expected original config in context, found %#v", resourceConfig) - } - return nil - }, - } - }, - }, - }, - "context has resource type": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - if resourceType, ok := reconcilers.RetrieveOriginalResourceType(ctx).(*resources.TestResource); !ok { - t.Errorf("expected original resource type not in context, found %#v", resourceType) - } - if resourceType, ok := reconcilers.RetrieveResourceType(ctx).(*resources.TestResource); !ok { - t.Errorf("expected resource type not in context, found %#v", resourceType) - } - return nil - }, - } - }, - }, - }, - "context can be augmented in Prepare and accessed in Cleanup": { - Request: testRequest, - StatusSubResourceTypes: []client.Object{ - &resources.TestResource{}, - }, - GivenObjects: []client.Object{ - resource, - }, - Prepare: func(t *testing.T, ctx context.Context, tc *rtesting.ReconcilerTestCase) (context.Context, error) { - key := "test-key" - value := "test-value" - ctx = context.WithValue(ctx, key, value) - - tc.Metadata["SubReconciler"] = func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - if v := ctx.Value(key); v != value { - t.Errorf("expected %s to be in context", key) - } - return nil - }, - } - } - tc.CleanUp = func(t *testing.T, ctx context.Context, tc *rtesting.ReconcilerTestCase) error { - if v := ctx.Value(key); v != value { - t.Errorf("expected %s to be in context", key) - } - return nil - } - - return ctx, nil - }, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { - skipStatusUpdate := false - if skip, ok := rtc.Metadata["SkipStatusUpdate"].(bool); ok { - skipStatusUpdate = skip - } - return &reconcilers.ResourceReconciler[*resources.TestResource]{ - Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c), - SkipStatusUpdate: skipStatusUpdate, - Config: c, - } - }) -} - -func TestAggregateReconciler(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - testFinalizer := "test.finalizer" - request := reconcilers.Request{ - NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, - } - - now := metav1.NewTime(time.Now().Truncate(time.Second)) - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - _ = clientgoscheme.AddToScheme(scheme) - - configMapCreate := diecorev1.ConfigMapBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }) - configMapGiven := configMapCreate. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - }) - - defaultAggregateReconciler := func(c reconcilers.Config) *reconcilers.AggregateReconciler[*corev1.ConfigMap] { - return &reconcilers.AggregateReconciler[*corev1.ConfigMap]{ - Request: request, - - DesiredResource: func(ctx context.Context, resource *corev1.ConfigMap) (*corev1.ConfigMap, error) { - resource.Data = map[string]string{ - "foo": "bar", - } - return resource, nil - }, - MergeBeforeUpdate: func(current, desired *corev1.ConfigMap) { - current.Data = desired.Data - }, - - Config: c, - } - } - - rts := rtesting.ReconcilerTests{ - "resource is in sync": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven. - AddData("foo", "bar"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - return defaultAggregateReconciler(c) - }, - }, - }, - "ignore other resources": { - Request: reconcilers.Request{ - NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: "not-it"}, - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - return defaultAggregateReconciler(c) - }, - }, - }, - "ignore terminating resources": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - return defaultAggregateReconciler(c) - }, - }, - }, - "create resource": { - Request: request, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - return defaultAggregateReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate. - AddData("foo", "bar"), - }, - }, - "update resource": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - return defaultAggregateReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Updated", - `Updated ConfigMap %q`, testName), - }, - ExpectUpdates: []client.Object{ - configMapGiven. - AddData("foo", "bar"), - }, - }, - "delete resource": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.DesiredResource = func(ctx context.Context, resource *corev1.ConfigMap) (*corev1.ConfigMap, error) { - return nil, nil - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - }, - "preserve immutable fields": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven. - AddData("foo", "bar"). - AddData("immutable", "field"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.HarmonizeImmutableFields = func(current, desired *corev1.ConfigMap) { - desired.Data["immutable"] = current.Data["immutable"] - } - return r - }, - }, - }, - "sanitize resource before logging": { - Request: request, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Sanitize = func(child *corev1.ConfigMap) interface{} { - return child.Name - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate. - AddData("foo", "bar"), - }, - }, - "sanitize is mutation safe": { - Request: request, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Sanitize = func(child *corev1.ConfigMap) interface{} { - child.Data["ignore"] = "me" - return child - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate. - AddData("foo", "bar"), - }, - }, - "error getting resources": { - Request: request, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("get", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - return defaultAggregateReconciler(c) - }, - }, - ShouldErr: true, - }, - "error creating resource": { - Request: request, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("create", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - return defaultAggregateReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeWarning, "CreationFailed", - `Failed to create ConfigMap %q: inducing failure for create ConfigMap`, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate. - AddData("foo", "bar"), - }, - ShouldErr: true, - }, - "error updating resource": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("update", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - return defaultAggregateReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeWarning, "UpdateFailed", - `Failed to update ConfigMap %q: inducing failure for update ConfigMap`, testName), - }, - ExpectUpdates: []client.Object{ - configMapGiven. - AddData("foo", "bar"), - }, - ShouldErr: true, - }, - "error deleting resource": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("delete", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.DesiredResource = func(ctx context.Context, resource *corev1.ConfigMap) (*corev1.ConfigMap, error) { - return nil, nil - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeWarning, "DeleteFailed", - `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - ShouldErr: true, - }, - "reconcile result": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven. - AddData("foo", "bar"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - SyncWithResult: func(ctx context.Context, resource *corev1.ConfigMap) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: time.Hour}, nil - }, - } - return r - }, - }, - ExpectedResult: reconcilers.Result{RequeueAfter: time.Hour}, - }, - "reconcile error": { - Request: request, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { - return fmt.Errorf("test error") - }, - } - return r - }, - }, - ShouldErr: true, - }, - "reconcile halted": { - Request: request, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Reconciler = reconcilers.Sequence[*corev1.ConfigMap]{ - &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { - return reconcilers.HaltSubReconcilers - }, - }, - &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { - return fmt.Errorf("test error") - }, - }, - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate. - AddData("foo", "bar"), - }, - }, - "reconcile halted with result": { - Request: request, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Reconciler = reconcilers.Sequence[*corev1.ConfigMap]{ - &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - SyncWithResult: func(ctx context.Context, resource *corev1.ConfigMap) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, reconcilers.HaltSubReconcilers - }, - }, - &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { - return fmt.Errorf("test error") - }, - }, - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(configMapGiven, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate. - AddData("foo", "bar"), - }, - ExpectedResult: reconcilers.Result{Requeue: true}, - }, - "context is stashable": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven. - AddData("foo", "bar"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { - var key reconcilers.StashKey = "foo" - // StashValue will panic if the context is not setup correctly - reconcilers.StashValue(ctx, key, "bar") - return nil - }, - } - return r - }, - }, - }, - "context has config": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven. - AddData("foo", "bar"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { - if config := reconcilers.RetrieveConfigOrDie(ctx); config != c { - t.Errorf("expected config in context, found %#v", config) - } - if resourceConfig := reconcilers.RetrieveOriginalConfigOrDie(ctx); resourceConfig != c { - t.Errorf("expected original config in context, found %#v", resourceConfig) - } - return nil - }, - } - return r - }, - }, - }, - "context has resource type": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven. - AddData("foo", "bar"), - }, - Metadata: map[string]interface{}{ - "Reconciler": func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { - if resourceType, ok := reconcilers.RetrieveOriginalResourceType(ctx).(*corev1.ConfigMap); !ok { - t.Errorf("expected original resource type not in context, found %#v", resourceType) - } - if resourceType, ok := reconcilers.RetrieveResourceType(ctx).(*corev1.ConfigMap); !ok { - t.Errorf("expected resource type not in context, found %#v", resourceType) - } - return nil - }, - } - return r - }, - }, - }, - "context can be augmented in Prepare and accessed in Cleanup": { - Request: request, - GivenObjects: []client.Object{ - configMapGiven. - AddData("foo", "bar"), - }, - Prepare: func(t *testing.T, ctx context.Context, tc *rtesting.ReconcilerTestCase) (context.Context, error) { - key := "test-key" - value := "test-value" - ctx = context.WithValue(ctx, key, value) - - tc.Metadata["Reconciler"] = func(t *testing.T, c reconcilers.Config) reconcile.Reconciler { - r := defaultAggregateReconciler(c) - r.Reconciler = &reconcilers.SyncReconciler[*corev1.ConfigMap]{ - Sync: func(ctx context.Context, resource *corev1.ConfigMap) error { - if v := ctx.Value(key); v != value { - t.Errorf("expected %s to be in context", key) - } - return nil - }, - } - return r - } - tc.CleanUp = func(t *testing.T, ctx context.Context, tc *rtesting.ReconcilerTestCase) error { - if v := ctx.Value(key); v != value { - t.Errorf("expected %s to be in context", key) - } - return nil - } - - return ctx, nil - }, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { - return rtc.Metadata["Reconciler"].(func(*testing.T, reconcilers.Config) reconcile.Reconciler)(t, c) - }) -} - -func TestSyncReconciler(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - testFinalizer := "test.finalizer" - - now := metav1.Now() - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), - ) - }) - - rts := rtesting.SubReconcilerTests[*resources.TestResource]{ - "sync success": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - return nil - }, - } - }, - }, - }, - "sync with result halted": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, reconcilers.HaltSubReconcilers - }, - } - }, - }, - ExpectedResult: reconcilers.Result{Requeue: true}, - ShouldErr: true, - }, - "sync error": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - return fmt.Errorf("syncreconciler error") - }, - } - }, - }, - ShouldErr: true, - }, - "missing sync method": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: nil, - } - }, - }, - ShouldPanic: true, - }, - "should not finalize non-deleted resources": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil - }, - FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - t.Errorf("reconciler should not call finalize for non-deleted resources") - return reconcilers.Result{RequeueAfter: 3 * time.Hour}, nil - }, - } - }, - }, - ExpectedResult: reconcile.Result{RequeueAfter: 2 * time.Hour}, - }, - "should finalize deleted resources": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - t.Errorf("reconciler should not call sync for deleted resources") - return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil - }, - FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 3 * time.Hour}, nil - }, - } - }, - }, - ExpectedResult: reconcile.Result{RequeueAfter: 3 * time.Hour}, - }, - "finalize can halt subreconcilers": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - t.Errorf("reconciler should not call sync for deleted resources") - return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil - }, - FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 3 * time.Hour}, reconcilers.HaltSubReconcilers - }, - } - }, - }, - ExpectedResult: reconcile.Result{RequeueAfter: 3 * time.Hour}, - ShouldErr: true, - }, - "should finalize and sync deleted resources when asked to": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncDuringFinalization: true, - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil - }, - FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 3 * time.Hour}, nil - }, - } - }, - }, - ExpectedResult: reconcile.Result{RequeueAfter: 2 * time.Hour}, - }, - "should finalize and sync deleted resources when asked to, shorter resync wins": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncDuringFinalization: true, - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 3 * time.Hour}, nil - }, - FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil - }, - } - }, - }, - ExpectedResult: reconcile.Result{RequeueAfter: 2 * time.Hour}, - }, - "finalize is optional": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - return nil - }, - } - }, - }, - }, - "finalize error": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - return nil - }, - Finalize: func(ctx context.Context, resource *resources.TestResource) error { - return fmt.Errorf("syncreconciler finalize error") - }, - } - }, - }, - ShouldErr: true, - }, - "context can be augmented in Prepare and accessed in Cleanup": { - Resource: resource.DieReleasePtr(), - Prepare: func(t *testing.T, ctx context.Context, tc *rtesting.SubReconcilerTestCase[*resources.TestResource]) (context.Context, error) { - key := "test-key" - value := "test-value" - ctx = context.WithValue(ctx, key, value) - - tc.Metadata["SubReconciler"] = func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - if v := ctx.Value(key); v != value { - t.Errorf("expected %s to be in context", key) - } - return nil - }, - } - } - tc.CleanUp = func(t *testing.T, ctx context.Context, tc *rtesting.SubReconcilerTestCase[*resources.TestResource]) error { - if v := ctx.Value(key); v != value { - t.Errorf("expected %s to be in context", key) - } - return nil - } - - return ctx, nil - }, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) - }) -} - -func TestChildReconciler(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - testFinalizer := "test.finalizer" - - now := metav1.NewTime(time.Now().Truncate(time.Second)) - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - _ = clientgoscheme.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), - ) - }) - resourceReady := resource. - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionTrue).Reason("Ready"), - ) - }) - - configMapCreate := diecorev1.ConfigMapBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - d.ControlledBy(resource, scheme) - }). - AddData("foo", "bar") - configMapGiven := configMapCreate. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - }) - - defaultChildReconciler := func(c reconcilers.Config) *reconcilers.ChildReconciler[*resources.TestResource, *corev1.ConfigMap, *corev1.ConfigMapList] { - return &reconcilers.ChildReconciler[*resources.TestResource, *corev1.ConfigMap, *corev1.ConfigMapList]{ - DesiredChild: func(ctx context.Context, parent *resources.TestResource) (*corev1.ConfigMap, error) { - if len(parent.Spec.Fields) == 0 { - return nil, nil - } - - return &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: parent.Namespace, - Name: parent.Name, - }, - Data: reconcilers.MergeMaps(parent.Spec.Fields), - }, nil - }, - MergeBeforeUpdate: func(current, desired *corev1.ConfigMap) { - current.Data = desired.Data - }, - ReflectChildStatusOnParent: func(ctx context.Context, parent *resources.TestResource, child *corev1.ConfigMap, err error) { - if err != nil { - if apierrs.IsAlreadyExists(err) { - name := err.(apierrs.APIStatus).Status().Details.Name - parent.Status.MarkNotReady("NameConflict", "%q already exists", name) - } - return - } - if child == nil { - parent.Status.Fields = nil - parent.Status.MarkReady() - return - } - parent.Status.Fields = reconcilers.MergeMaps(child.Data) - parent.Status.MarkReady() - }, - } - } - - rts := rtesting.SubReconcilerTests[*resources.TestResource]{ - "preserve no child": { - Resource: resourceReady.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - }, - "child is in sync": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - }, - "child is in sync, in a different namespace": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace("other-ns") - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.ListOptions = func(ctx context.Context, parent *resources.TestResource) []client.ListOption { - return []client.ListOption{ - client.InNamespace("other-ns"), - } - } - return r - }, - }, - }, - "create child": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - ExpectCreates: []client.Object{ - configMapCreate, - }, - }, - "create child with custom owner reference": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - desiredChild := r.DesiredChild - r.DesiredChild = func(ctx context.Context, resource *resources.TestResource) (*corev1.ConfigMap, error) { - child, err := desiredChild(ctx, resource) - if child != nil { - child.OwnerReferences = []metav1.OwnerReference{ - { - APIVersion: resources.GroupVersion.String(), - Kind: "TestResource", - Name: resource.GetName(), - UID: resource.GetUID(), - Controller: pointer.Bool(true), - // the default controller ref is set to block - BlockOwnerDeletion: pointer.Bool(false), - }, - } - } - return child, err - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - ExpectCreates: []client.Object{ - configMapCreate. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences( - metav1.OwnerReference{ - APIVersion: resources.GroupVersion.String(), - Kind: "TestResource", - Name: resource.GetName(), - UID: resource.GetUID(), - Controller: pointer.Bool(true), - BlockOwnerDeletion: pointer.Bool(false), - }, - ) - }), - }, - }, - "create child with finalizer": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } - return r - }, - }, - ExpectTracks: []rtesting.TrackRequest{ - rtesting.NewTrackRequest(configMapCreate, resource, scheme), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", - `Patched finalizer %q`, testFinalizer), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer) - d.ResourceVersion("1000") - }). - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - ExpectCreates: []client.Object{ - configMapCreate. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), - }, - }, - }, - "update child": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", - `Updated ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - DieReleasePtr(), - ExpectUpdates: []client.Object{ - configMapGiven. - AddData("new", "field"), - }, - }, - "update child, preserve finalizers": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer, "some.other.finalizer") - }). - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } - return r - }, - }, - ExpectTracks: []rtesting.TrackRequest{ - rtesting.NewTrackRequest(configMapGiven, resource, scheme), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", - `Updated ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer, "some.other.finalizer") - }). - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - DieReleasePtr(), - ExpectUpdates: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }). - AddData("new", "field"), - }, - }, - "update child, restoring missing finalizer": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } - return r - }, - }, - ExpectTracks: []rtesting.TrackRequest{ - rtesting.NewTrackRequest(configMapGiven, resource, scheme), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", - `Patched finalizer %q`, testFinalizer), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", - `Updated ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer) - d.ResourceVersion("1000") - }). - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - DieReleasePtr(), - ExpectUpdates: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }). - AddData("new", "field"), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), - }, - }, - }, - "delete child": { - Resource: resourceReady.DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - }, - "delete child, preserve finalizers": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer, "some.other.finalizer") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - }, - "ignore extraneous children": { - Resource: resourceReady.DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { - return false - } - return r - }, - }, - }, - "delete duplicate children": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Name("extra-child-1") - }), - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Name("extra-child-2") - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, "extra-child-1"), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, "extra-child-2"), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-1"}, - {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-2"}, - }, - ExpectCreates: []client.Object{ - configMapCreate, - }, - }, - "delete child during finalization": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - }, - "clear finalizer after child fully deleted": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", - `Patched finalizer %q`, testFinalizer), - }, - ExpectResource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers() - d.ResourceVersion("1000") - }). - DieReleasePtr(), - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), - }, - }, - }, - "preserve finalizer for terminating child": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } - return r - }, - }, - }, - "child name collision": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("create", "ConfigMap", rtesting.InduceFailureOpts{ - Error: apierrs.NewAlreadyExists(schema.GroupResource{}, testName), - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionFalse). - Reason("NameConflict").Message(`"test-resource" already exists`), - ) - }). - DieReleasePtr(), - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", - "Failed to create ConfigMap %q: %q already exists", testName, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate, - }, - }, - "child name collision, stale informer cache": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - APIGivenObjects: []client.Object{ - configMapGiven, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("create", "ConfigMap", rtesting.InduceFailureOpts{ - Error: apierrs.NewAlreadyExists(schema.GroupResource{}, testName), - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", - "Failed to create ConfigMap %q: %q already exists", testName, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate, - }, - ShouldErr: true, - }, - "preserve immutable fields": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - d.AddField("immutable", "field") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven. - AddData("immutable", "field"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.HarmonizeImmutableFields = func(current, desired *corev1.ConfigMap) { - desired.Data["immutable"] = current.Data["immutable"] - } - return r - }, - }, - }, - "status only reconcile": { - Resource: resource.DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven, - }, - ExpectResource: resourceReady. - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.DesiredChild = func(ctx context.Context, parent *resources.TestResource) (*corev1.ConfigMap, error) { - return nil, reconcilers.OnlyReconcileChildStatus - } - return r - }, - }, - }, - "sanitize child before logging": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Sanitize = func(child *corev1.ConfigMap) interface{} { - return child.Name - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - ExpectCreates: []client.Object{ - configMapCreate, - }, - }, - "sanitize is mutation safe": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Sanitize = func(child *corev1.ConfigMap) interface{} { - child.Data["ignore"] = "me" - return child - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - ExpectCreates: []client.Object{ - configMapCreate, - }, - }, - "error listing children": { - Resource: resourceReady.DieReleasePtr(), - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("list", "ConfigMapList"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ShouldErr: true, - }, - "error creating child": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("create", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", - `Failed to create ConfigMap %q: inducing failure for create ConfigMap`, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate, - }, - ShouldErr: true, - }, - "error adding finalizer": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } - return r - }, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("patch", "TestResource"), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", - `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), - }, - }, - ShouldErr: true, - }, - "error clearing finalizer": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *resources.TestResource, child *corev1.ConfigMap) bool { return true } - return r - }, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("patch", "TestResource"), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", - `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), - }, - }, - ShouldErr: true, - }, - "error updating child": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("update", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "UpdateFailed", - `Failed to update ConfigMap %q: inducing failure for update ConfigMap`, testName), - }, - ExpectUpdates: []client.Object{ - configMapGiven. - AddData("new", "field"), - }, - ShouldErr: true, - }, - "error deleting child": { - Resource: resourceReady.DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("delete", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "DeleteFailed", - `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - ShouldErr: true, - }, - "error deleting duplicate children": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleasePtr(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Name("extra-child-1") - }), - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Name("extra-child-2") - }), - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("delete", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "DeleteFailed", - `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, "extra-child-1"), - }, - ExpectDeletes: []rtesting.DeleteRef{ - {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-1"}, - }, - ShouldErr: true, - }, - "error creating desired child": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - r := defaultChildReconciler(c) - r.DesiredChild = func(ctx context.Context, parent *resources.TestResource) (*corev1.ConfigMap, error) { - return nil, fmt.Errorf("test error") - } - return r - }, - }, - ShouldErr: true, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) - }) -} - -func TestChildReconciler_Unstructured(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - testFinalizer := "test.finalizer" - - now := metav1.NewTime(time.Now().Truncate(time.Second)) - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - _ = clientgoscheme.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - APIVersion("testing.reconciler.runtime/v1"). - Kind("TestResource"). - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), - ) - }) - resourceReady := resource. - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionTrue).Reason("Ready"), - ) - }) - - configMapCreate := diecorev1.ConfigMapBlank. - APIVersion("v1"). - Kind("ConfigMap"). - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - d.ControlledBy(resource, scheme) - }). - AddData("foo", "bar") - configMapGiven := configMapCreate. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - }) - - defaultChildReconciler := func(c reconcilers.Config) *reconcilers.ChildReconciler[*unstructured.Unstructured, *unstructured.Unstructured, *unstructured.UnstructuredList] { - return &reconcilers.ChildReconciler[*unstructured.Unstructured, *unstructured.Unstructured, *unstructured.UnstructuredList]{ - ChildType: &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "v1", - "kind": "ConfigMap", - }, - }, - ChildListType: &unstructured.UnstructuredList{ - Object: map[string]interface{}{ - "apiVersion": "v1", - "kind": "ConfigMapList", - }, - }, - DesiredChild: func(ctx context.Context, parent *unstructured.Unstructured) (*unstructured.Unstructured, error) { - fields, ok, _ := unstructured.NestedMap(parent.Object, "spec", "fields") - if !ok || len(fields) == 0 { - return nil, nil - } - - child := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": map[string]interface{}{ - "namespace": parent.GetNamespace(), - "name": parent.GetName(), - }, - "data": map[string]interface{}{}, - }, - } - for k, v := range fields { - unstructured.SetNestedField(child.Object, v, "data", k) - } - - return child, nil - }, - MergeBeforeUpdate: func(current, desired *unstructured.Unstructured) { - current.Object["data"] = desired.Object["data"] - }, - ReflectChildStatusOnParent: func(ctx context.Context, parent *unstructured.Unstructured, child *unstructured.Unstructured, err error) { - if err != nil { - if apierrs.IsAlreadyExists(err) { - name := err.(apierrs.APIStatus).Status().Details.Name - readyCond := map[string]interface{}{ - "type": "Ready", - "status": "False", - "reason": "NameConflict", - "message": fmt.Sprintf("%q already exists", name), - } - unstructured.SetNestedSlice(parent.Object, []interface{}{readyCond}, "status", "conditions") - } - return - } - if child == nil { - unstructured.RemoveNestedField(parent.Object, "status", "fields") - readyCond := map[string]interface{}{ - "type": "Ready", - "status": "True", - "reason": "Ready", - "message": "", - } - unstructured.SetNestedSlice(parent.Object, []interface{}{readyCond}, "status", "conditions") - return - } - unstructured.SetNestedMap(parent.Object, map[string]interface{}{}, "status", "fields") - for k, v := range child.Object["data"].(map[string]interface{}) { - unstructured.SetNestedField(parent.Object, v, "status", "fields", k) - } - readyCond := map[string]interface{}{ - "type": "Ready", - "status": "True", - "reason": "Ready", - "message": "", - } - unstructured.SetNestedSlice(parent.Object, []interface{}{readyCond}, "status", "conditions") - }, - } - } - - rts := rtesting.SubReconcilerTests[*unstructured.Unstructured]{ - "preserve no child": { - Resource: resourceReady.DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - }, - "child is in sync": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - }, - "child is in sync, in a different namespace": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace("other-ns") - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.ListOptions = func(ctx context.Context, parent *unstructured.Unstructured) []client.ListOption { - return []client.ListOption{ - client.InNamespace("other-ns"), - } - } - return r - }, - }, - }, - "create child": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - ExpectCreates: []client.Object{ - configMapCreate.DieReleaseUnstructured(), - }, - }, - "create child with finalizer": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } - return r - }, - }, - ExpectTracks: []rtesting.TrackRequest{ - rtesting.NewTrackRequest(configMapCreate, resource, scheme), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", - `Patched finalizer %q`, testFinalizer), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer) - d.ResourceVersion("1000") - }). - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - ExpectCreates: []client.Object{ - configMapCreate. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }). - DieReleaseUnstructured(), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), - }, - }, - }, - "update child": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", - `Updated ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - DieReleaseUnstructured(), - ExpectUpdates: []client.Object{ - configMapGiven. - AddData("new", "field"). - DieReleaseUnstructured(), - }, - }, - "update child, preserve finalizers": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer, "some.other.finalizer") - }). - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } - return r - }, - }, - ExpectTracks: []rtesting.TrackRequest{ - rtesting.NewTrackRequest(configMapGiven, resource, scheme), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", - `Updated ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer, "some.other.finalizer") - }). - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - DieReleaseUnstructured(), - ExpectUpdates: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }). - AddData("new", "field"). - DieReleaseUnstructured(), - }, - }, - "update child, restoring missing finalizer": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } - return r - }, - }, - ExpectTracks: []rtesting.TrackRequest{ - rtesting.NewTrackRequest(configMapGiven, resource, scheme), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", - `Patched finalizer %q`, testFinalizer), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", - `Updated ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer) - d.ResourceVersion("1000") - }). - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - DieReleaseUnstructured(), - ExpectUpdates: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }). - AddData("new", "field"). - DieReleaseUnstructured(), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), - }, - }, - }, - "delete child": { - Resource: resourceReady.DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - }, - "delete child, preserve finalizers": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer, "some.other.finalizer") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - }, - "ignore extraneous children": { - Resource: resourceReady.DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven, - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { - return false - } - return r - }, - }, - }, - "delete duplicate children": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Name("extra-child-1") - }), - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Name("extra-child-2") - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, "extra-child-1"), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, "extra-child-2"), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-1"}, - {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-2"}, - }, - ExpectCreates: []client.Object{ - configMapCreate.DieReleaseUnstructured(), - }, - }, - "delete child during finalization": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", - `Deleted ConfigMap %q`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - }, - "clear finalizer after child fully deleted": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", - `Patched finalizer %q`, testFinalizer), - }, - ExpectResource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers() - d.ResourceVersion("1000") - }). - DieReleaseUnstructured(), - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":[],"resourceVersion":"999"}}`), - }, - }, - }, - "preserve finalizer for terminating child": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.OwnerReferences() - d.DeletionTimestamp(&now) - d.Finalizers(testFinalizer) - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } - return r - }, - }, - }, - "child name collision": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("create", "ConfigMap", rtesting.InduceFailureOpts{ - Error: apierrs.NewAlreadyExists(schema.GroupResource{}, testName), - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionFalse). - Reason("NameConflict").Message(`"test-resource" already exists`), - ) - }). - DieReleaseUnstructured(), - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", - "Failed to create ConfigMap %q: %q already exists", testName, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate.DieReleaseUnstructured(), - }, - }, - "child name collision, stale informer cache": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - APIGivenObjects: []client.Object{ - configMapGiven, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("create", "ConfigMap", rtesting.InduceFailureOpts{ - Error: apierrs.NewAlreadyExists(schema.GroupResource{}, testName), - }), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", - "Failed to create ConfigMap %q: %q already exists", testName, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate.DieReleaseUnstructured(), - }, - ShouldErr: true, - }, - "preserve immutable fields": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - d.AddField("immutable", "field") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven. - AddData("immutable", "field"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.HarmonizeImmutableFields = func(current, desired *unstructured.Unstructured) { - immutable, _, _ := unstructured.NestedString(current.Object, "data", "immutable") - unstructured.SetNestedField(desired.Object, immutable, "data", "immutable") - } - return r - }, - }, - }, - "status only reconcile": { - Resource: resource.DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven, - }, - ExpectResource: resourceReady. - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.DesiredChild = func(ctx context.Context, parent *unstructured.Unstructured) (*unstructured.Unstructured, error) { - return nil, reconcilers.OnlyReconcileChildStatus - } - return r - }, - }, - }, - "sanitize child before logging": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Sanitize = func(child *unstructured.Unstructured) interface{} { - return child.GetName() - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - ExpectCreates: []client.Object{ - configMapCreate.DieReleaseUnstructured(), - }, - }, - "sanitize is mutation safe": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Sanitize = func(child *unstructured.Unstructured) interface{} { - unstructured.SetNestedField(child.Object, "me", "data", "ignore") - return child - } - return r - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", - `Created ConfigMap %q`, testName), - }, - ExpectResource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - ExpectCreates: []client.Object{ - configMapCreate.DieReleaseUnstructured(), - }, - }, - "error listing children": { - Resource: resourceReady.DieReleaseUnstructured(), - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("list", "ConfigMapList"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ShouldErr: true, - }, - "error creating child": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("create", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", - `Failed to create ConfigMap %q: inducing failure for create ConfigMap`, testName), - }, - ExpectCreates: []client.Object{ - configMapCreate.DieReleaseUnstructured(), - }, - ShouldErr: true, - }, - "error adding finalizer": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } - return r - }, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("patch", "TestResource"), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", - `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":["test.finalizer"],"resourceVersion":"999"}}`), - }, - }, - ShouldErr: true, - }, - "error clearing finalizer": { - Resource: resourceReady. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer) - }). - DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.Finalizer = testFinalizer - r.SkipOwnerReference = true - r.OurChild = func(parent *unstructured.Unstructured, child *unstructured.Unstructured) bool { return true } - return r - }, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("patch", "TestResource"), - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", - `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":[],"resourceVersion":"999"}}`), - }, - }, - ShouldErr: true, - }, - "error updating child": { - Resource: resourceReady. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - d.AddField("new", "field") - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("update", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "UpdateFailed", - `Failed to update ConfigMap %q: inducing failure for update ConfigMap`, testName), - }, - ExpectUpdates: []client.Object{ - configMapGiven. - AddData("new", "field"). - DieReleaseUnstructured(), - }, - ShouldErr: true, - }, - "error deleting child": { - Resource: resourceReady.DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven, - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("delete", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "DeleteFailed", - `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, testName), - }, - ExpectDeletes: []rtesting.DeleteRef{ - rtesting.NewDeleteRefFromObject(configMapGiven, scheme), - }, - ShouldErr: true, - }, - "error deleting duplicate children": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.AddField("foo", "bar") - }). - DieReleaseUnstructured(), - GivenObjects: []client.Object{ - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Name("extra-child-1") - }), - configMapGiven. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Name("extra-child-2") - }), - }, - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("delete", "ConfigMap"), - }, - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return defaultChildReconciler(c) - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "DeleteFailed", - `Failed to delete ConfigMap %q: inducing failure for delete ConfigMap`, "extra-child-1"), - }, - ExpectDeletes: []rtesting.DeleteRef{ - {Group: "", Kind: "ConfigMap", Namespace: testNamespace, Name: "extra-child-1"}, - }, - ShouldErr: true, - }, - "error creating desired child": { - Resource: resource.DieReleaseUnstructured(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - r := defaultChildReconciler(c) - r.DesiredChild = func(ctx context.Context, parent *unstructured.Unstructured) (*unstructured.Unstructured, error) { - return nil, fmt.Errorf("test error") - } - return r - }, - }, - ShouldErr: true, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*unstructured.Unstructured], c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { - return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured])(t, c) - }) -} - -func TestSequence(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), - ) - }) - - rts := rtesting.SubReconcilerTests[*resources.TestResource]{ - "sub reconciler erred": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - return fmt.Errorf("reconciler error") - }, - }, - } - }, - }, - ShouldErr: true, - }, - "preserves result, sub reconciler halted": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil - }, - }, &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 2 * time.Minute}, reconcilers.HaltSubReconcilers - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, - ShouldErr: true, - }, - "preserves result, Requeue": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, nil - }, - } - }, - }, - ExpectedResult: reconcilers.Result{Requeue: true}, - }, - "preserves result, RequeueAfter": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, - }, - "ignores result on err": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, fmt.Errorf("test error") - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{}, - ShouldErr: true, - }, - "Requeue + empty => Requeue": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, nil - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{Requeue: true}, - }, - "empty + Requeue => Requeue": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{}, nil - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{Requeue: true}, - }, - "RequeueAfter + empty => RequeueAfter": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, - }, - "empty + RequeueAfter => RequeueAfter": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{}, nil - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, - }, - "RequeueAfter + Requeue => RequeueAfter": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, - }, - "Requeue + RequeueAfter => RequeueAfter": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, nil - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, - }, - "RequeueAfter(1m) + RequeueAfter(2m) => RequeueAfter(1m)": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 2 * time.Minute}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, - }, - "RequeueAfter(2m) + RequeueAfter(1m) => RequeueAfter(1m)": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return reconcilers.Sequence[*resources.TestResource]{ - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 2 * time.Minute}, nil - }, - }, - &reconcilers.SyncReconciler[*resources.TestResource]{ - SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { - return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) - }) -} - -func TestCastResource(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - _ = clientgoscheme.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }). - StatusDie(func(d *dies.TestResourceStatusDie) { - d.ConditionsDie( - diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), - ) - }) - - rts := rtesting.SubReconcilerTests[*resources.TestResource]{ - "sync success": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.TemplateDie(func(d *diecorev1.PodTemplateSpecDie) { - d.SpecDie(func(d *diecorev1.PodSpecDie) { - d.ContainerDie("test-container", func(d *diecorev1.ContainerDie) {}) - }) - }) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ - Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ - Sync: func(ctx context.Context, resource *appsv1.Deployment) error { - reconcilers.RetrieveConfigOrDie(ctx). - Recorder.Event(resource, corev1.EventTypeNormal, "Test", - resource.Spec.Template.Spec.Containers[0].Name) - return nil - }, - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Test", "test-container"), - }, - }, - "cast mutation": { - Resource: resource.DieReleasePtr(), - ExpectResource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.TemplateDie(func(d *diecorev1.PodTemplateSpecDie) { - d.MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Name("mutation") - }) - }) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ - Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ - Sync: func(ctx context.Context, resource *appsv1.Deployment) error { - // mutation that exists on the original resource and will be reflected - resource.Spec.Template.Name = "mutation" - // mutation that does not exists on the original resource and will be dropped - resource.Spec.Paused = true - return nil - }, - }, - } - }, - }, - }, - "return subreconciler result": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ - Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ - SyncWithResult: func(ctx context.Context, resource *appsv1.Deployment) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, nil - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{Requeue: true}, - }, - "return subreconciler err, preserves result": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.CastResource[*resources.TestResource, *appsv1.Deployment]{ - Reconciler: &reconcilers.SyncReconciler[*appsv1.Deployment]{ - SyncWithResult: func(ctx context.Context, resource *appsv1.Deployment) (reconcilers.Result, error) { - return reconcilers.Result{Requeue: true}, fmt.Errorf("subreconciler error") - }, - }, - } - }, - }, - ExpectedResult: reconcilers.Result{Requeue: true}, - ShouldErr: true, - }, - "marshal error": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.ErrOnMarshal(true) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.CastResource[*resources.TestResource, *resources.TestResource]{ - Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - c.Recorder.Event(resource, corev1.EventTypeNormal, "Test", resource.Name) - return nil - }, - }, - } - }, - }, - ShouldErr: true, - }, - "unmarshal error": { - Resource: resource. - SpecDie(func(d *dies.TestResourceSpecDie) { - d.ErrOnUnmarshal(true) - }). - DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return &reconcilers.CastResource[*resources.TestResource, *resources.TestResource]{ - Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - c.Recorder.Event(resource, corev1.EventTypeNormal, "Test", resource.Name) - return nil - }, - }, - } - }, - }, - ShouldErr: true, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) - }) -} - -func TestWithConfig(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }) - - rts := rtesting.SubReconcilerTests[*resources.TestResource]{ - "with config": { - Resource: resource.DieReleasePtr(), - Metadata: map[string]interface{}{ - "SubReconciler": func(t *testing.T, oc reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - c := reconcilers.Config{ - Tracker: tracker.New(oc.Scheme(), 0), - } - - return &reconcilers.WithConfig[*resources.TestResource]{ - Config: func(ctx context.Context, _ reconcilers.Config) (reconcilers.Config, error) { - return c, nil - }, - Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, parent *resources.TestResource) error { - rc := reconcilers.RetrieveConfigOrDie(ctx) - roc := reconcilers.RetrieveOriginalConfigOrDie(ctx) - - if rc != c { - t.Errorf("unexpected config") - } - if roc != oc { - t.Errorf("unexpected original config") - } - - oc.Recorder.Event(resource, corev1.EventTypeNormal, "AllGood", "") - - return nil - }, - }, - } - }, - }, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "AllGood", ""), - }, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) - }) -} - -func TestWithFinalizer(t *testing.T) { - testNamespace := "test-namespace" - testName := "test-resource" - testFinalizer := "test-finalizer" - - now := &metav1.Time{Time: time.Now().Truncate(time.Second)} - - scheme := runtime.NewScheme() - _ = resources.AddToScheme(scheme) - - resource := dies.TestResourceBlank. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Namespace(testNamespace) - d.Name(testName) - }) - - rts := rtesting.SubReconcilerTests[*resources.TestResource]{ - "in sync": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Sync", ""), - }, - }, - "add finalizer": { - Resource: resource.DieReleasePtr(), - ExpectResource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.Finalizers(testFinalizer) - d.ResourceVersion("1000") - }). - DieReleasePtr(), - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", - `Patched finalizer %q`, testFinalizer), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Sync", ""), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":["test-finalizer"],"resourceVersion":"999"}}`), - }, - }, - }, - "error adding finalizer": { - Resource: resource.DieReleasePtr(), - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("patch", "TestResource"), - }, - ShouldErr: true, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", - `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":["test-finalizer"],"resourceVersion":"999"}}`), - }, - }, - }, - "clear finalizer": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - ExpectResource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(now) - d.ResourceVersion("1000") - }). - DieReleasePtr(), - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Finalize", ""), - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "FinalizerPatched", - `Patched finalizer %q`, testFinalizer), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), - }, - }, - }, - "error clearing finalizer": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - WithReactors: []rtesting.ReactionFunc{ - rtesting.InduceFailure("patch", "TestResource"), - }, - ShouldErr: true, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Finalize", ""), - rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "FinalizerPatchFailed", - `Failed to patch finalizer %q: inducing failure for patch TestResource`, testFinalizer), - }, - ExpectPatches: []rtesting.PatchRef{ - { - Group: "testing.reconciler.runtime", - Kind: "TestResource", - Namespace: testNamespace, - Name: testName, - PatchType: types.MergePatchType, - Patch: []byte(`{"metadata":{"finalizers":null,"resourceVersion":"999"}}`), - }, - }, - }, - "keep finalizer on error": { - Resource: resource. - MetadataDie(func(d *diemetav1.ObjectMetaDie) { - d.DeletionTimestamp(now) - d.Finalizers(testFinalizer) - }). - DieReleasePtr(), - ShouldErr: true, - ExpectEvents: []rtesting.Event{ - rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Finalize", ""), - }, - Metadata: map[string]interface{}{ - "FinalizerError": fmt.Errorf("finalize error"), - }, - }, - } - - rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { - var syncErr, finalizeErr error - if err, ok := rtc.Metadata["SyncError"]; ok { - syncErr = err.(error) - } - if err, ok := rtc.Metadata["FinalizerError"]; ok { - finalizeErr = err.(error) - } - - return &reconcilers.WithFinalizer[*resources.TestResource]{ - Finalizer: testFinalizer, - Reconciler: &reconcilers.SyncReconciler[*resources.TestResource]{ - Sync: func(ctx context.Context, resource *resources.TestResource) error { - c.Recorder.Event(resource, corev1.EventTypeNormal, "Sync", "") - return syncErr - }, - Finalize: func(ctx context.Context, resource *resources.TestResource) error { - c.Recorder.Event(resource, corev1.EventTypeNormal, "Finalize", "") - return finalizeErr - }, - }, - } - }) -} diff --git a/reconcilers/resource.go b/reconcilers/resource.go new file mode 100644 index 0000000..55379e1 --- /dev/null +++ b/reconcilers/resource.go @@ -0,0 +1,344 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + + "github.com/go-logr/logr" + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrs "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + "github.com/vmware-labs/reconciler-runtime/internal" +) + +var ( + _ reconcile.Reconciler = (*ResourceReconciler[client.Object])(nil) +) + +// ResourceReconciler is a controller-runtime reconciler that reconciles a given +// existing resource. The Type resource is fetched for the reconciler +// request and passed in turn to each SubReconciler. Finally, the reconciled +// resource's status is compared with the original status, updating the API +// server if needed. +type ResourceReconciler[Type client.Object] struct { + // Name used to identify this reconciler. Defaults to `{Type}ResourceReconciler`. Ideally + // unique, but not required to be so. + // + // +optional + Name string + + // Setup performs initialization on the manager and builder this reconciler + // will run with. It's common to setup field indexes and watch resources. + // + // +optional + Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error + + // Type of resource to reconcile. Required when the generic type is not a + // struct, or is unstructured. + // + // +optional + Type Type + + // SkipStatusUpdate when true, the resource's status will not be updated. If this + // is not the primary reconciler for a resource, skipping status updates can avoid + // conflicts. Finalizers and events are still actionable. + SkipStatusUpdate bool + + // Reconciler is called for each reconciler request with the resource being reconciled. + // Typically, Reconciler is a Sequence of multiple SubReconcilers. + // + // When HaltSubReconcilers is returned as an error, execution continues as if no error was + // returned. + Reconciler SubReconciler[Type] + + Config Config + + lazyInit sync.Once +} + +func (r *ResourceReconciler[T]) init() { + r.lazyInit.Do(func() { + if internal.IsNil(r.Type) { + var nilT T + r.Type = newEmpty(nilT).(T) + } + if r.Name == "" { + r.Name = fmt.Sprintf("%sResourceReconciler", typeName(r.Type)) + } + }) +} + +func (r *ResourceReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + _, err := r.SetupWithManagerYieldingController(ctx, mgr) + return err +} + +func (r *ResourceReconciler[T]) SetupWithManagerYieldingController(ctx context.Context, mgr ctrl.Manager) (controller.Controller, error) { + r.init() + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name). + WithValues("resourceType", gvk(r.Type, r.Config.Scheme())) + ctx = logr.NewContext(ctx, log) + + ctx = StashConfig(ctx, r.Config) + ctx = StashOriginalConfig(ctx, r.Config) + ctx = StashResourceType(ctx, r.Type) + ctx = StashOriginalResourceType(ctx, r.Type) + + if err := r.validate(ctx); err != nil { + return nil, err + } + + bldr := ctrl.NewControllerManagedBy(mgr).For(r.Type) + if r.Setup != nil { + if err := r.Setup(ctx, mgr, bldr); err != nil { + return nil, err + } + } + if err := r.Reconciler.SetupWithManager(ctx, mgr, bldr); err != nil { + return nil, err + } + return bldr.Build(r) +} + +func (r *ResourceReconciler[T]) validate(ctx context.Context) error { + // validate Reconciler value + if r.Reconciler == nil { + return fmt.Errorf("ResourceReconciler %q must define Reconciler", r.Name) + } + + // warn users of common pitfalls. These are not blockers. + + log := logr.FromContextOrDiscard(ctx) + + resourceType := reflect.TypeOf(r.Type).Elem() + statusField, hasStatus := resourceType.FieldByName("Status") + if !hasStatus { + log.Info("resource missing status field, operations related to status will be skipped") + return nil + } + + statusType := statusField.Type + if statusType.Kind() == reflect.Ptr { + log.Info("resource status is nilable, status is typically a struct") + statusType = statusType.Elem() + } + + observedGenerationField, hasObservedGeneration := statusType.FieldByName("ObservedGeneration") + if !hasObservedGeneration || observedGenerationField.Type.Kind() != reflect.Int64 { + log.Info("resource status missing ObservedGeneration field of type int64, generation will not be managed") + } + + initializeConditionsMethod, hasInitializeConditions := reflect.PtrTo(statusType).MethodByName("InitializeConditions") + if !hasInitializeConditions || initializeConditionsMethod.Type.NumIn() != 1 || initializeConditionsMethod.Type.NumOut() != 0 { + log.Info("resource status missing InitializeConditions() method, conditions will not be auto-initialized") + } + + conditionsField, hasConditions := statusType.FieldByName("Conditions") + if !hasConditions || !conditionsField.Type.AssignableTo(reflect.TypeOf([]metav1.Condition{})) { + log.Info("resource status is missing field Conditions of type []metav1.Condition, condition timestamps will not be managed") + } + + return nil +} + +func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req Request) (Result, error) { + r.init() + + ctx = WithStash(ctx) + + c := r.Config + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name). + WithValues("resourceType", gvk(r.Type, c.Scheme())) + ctx = logr.NewContext(ctx, log) + + ctx = StashRequest(ctx, req) + ctx = StashConfig(ctx, c) + ctx = StashOriginalConfig(ctx, c) + ctx = StashOriginalResourceType(ctx, r.Type) + ctx = StashResourceType(ctx, r.Type) + originalResource := r.Type.DeepCopyObject().(T) + + if err := c.Get(ctx, req.NamespacedName, originalResource); err != nil { + if apierrs.IsNotFound(err) { + // we'll ignore not-found errors, since they can't be fixed by an immediate + // requeue (we'll need to wait for a new notification), and we can get them + // on deleted requests. + return Result{}, nil + } + log.Error(err, "unable to fetch resource") + return Result{}, err + } + resource := originalResource.DeepCopyObject().(T) + + if defaulter, ok := client.Object(resource).(webhook.Defaulter); ok { + // resource.Default() + defaulter.Default() + } + + r.initializeConditions(resource) + result, err := r.reconcile(ctx, resource) + + if r.SkipStatusUpdate { + return result, err + } + + // attempt to restore last transition time for unchanged conditions + r.syncLastTransitionTime(r.conditions(resource), r.conditions(originalResource)) + + // check if status has changed before updating + resourceStatus, originalResourceStatus := r.status(resource), r.status(originalResource) + if !equality.Semantic.DeepEqual(resourceStatus, originalResourceStatus) && resource.GetDeletionTimestamp() == nil { + // update status + log.Info("updating status", "diff", cmp.Diff(originalResourceStatus, resourceStatus)) + if updateErr := c.Status().Update(ctx, resource); updateErr != nil { + log.Error(updateErr, "unable to update status") + c.Recorder.Eventf(resource, corev1.EventTypeWarning, "StatusUpdateFailed", + "Failed to update status: %v", updateErr) + return Result{}, updateErr + } + c.Recorder.Eventf(resource, corev1.EventTypeNormal, "StatusUpdated", + "Updated status") + } + + // return original reconcile result + return result, err +} + +func (r *ResourceReconciler[T]) reconcile(ctx context.Context, resource T) (Result, error) { + if resource.GetDeletionTimestamp() != nil && len(resource.GetFinalizers()) == 0 { + // resource is being deleted and has no pending finalizers, nothing to do + return Result{}, nil + } + + result, err := r.Reconciler.Reconcile(ctx, resource) + if err != nil && !errors.Is(err, HaltSubReconcilers) { + return Result{}, err + } + + r.copyGeneration(resource) + return result, nil +} + +func (r *ResourceReconciler[T]) initializeConditions(obj T) { + status := r.status(obj) + if status == nil { + return + } + initializeConditions := reflect.ValueOf(status).MethodByName("InitializeConditions") + if !initializeConditions.IsValid() { + return + } + if t := initializeConditions.Type(); t.Kind() != reflect.Func || t.NumIn() != 0 || t.NumOut() != 0 { + return + } + initializeConditions.Call([]reflect.Value{}) +} + +func (r *ResourceReconciler[T]) conditions(obj T) []metav1.Condition { + // return obj.Status.Conditions + status := r.status(obj) + if status == nil { + return nil + } + statusValue := reflect.ValueOf(status) + if statusValue.Type().Kind() == reflect.Map { + return nil + } + statusValue = statusValue.Elem() + conditionsValue := statusValue.FieldByName("Conditions") + if !conditionsValue.IsValid() || conditionsValue.IsZero() { + return nil + } + conditions, ok := conditionsValue.Interface().([]metav1.Condition) + if !ok { + return nil + } + return conditions +} + +func (r *ResourceReconciler[T]) copyGeneration(obj T) { + // obj.Status.ObservedGeneration = obj.Generation + status := r.status(obj) + if status == nil { + return + } + statusValue := reflect.ValueOf(status) + if statusValue.Type().Kind() == reflect.Map { + return + } + statusValue = statusValue.Elem() + if !statusValue.IsValid() { + return + } + observedGenerationValue := statusValue.FieldByName("ObservedGeneration") + if observedGenerationValue.Kind() != reflect.Int64 || !observedGenerationValue.CanSet() { + return + } + generation := obj.GetGeneration() + observedGenerationValue.SetInt(generation) +} + +func (r *ResourceReconciler[T]) hasStatus(obj T) bool { + status := r.status(obj) + return status != nil +} + +func (r *ResourceReconciler[T]) status(obj T) interface{} { + if client.Object(obj) == nil { + return nil + } + if u, ok := client.Object(obj).(*unstructured.Unstructured); ok { + return u.UnstructuredContent()["status"] + } + statusValue := reflect.ValueOf(obj).Elem().FieldByName("Status") + if statusValue.Kind() == reflect.Ptr { + statusValue = statusValue.Elem() + } + if !statusValue.IsValid() || !statusValue.CanAddr() { + return nil + } + return statusValue.Addr().Interface() +} + +// syncLastTransitionTime restores a condition's LastTransitionTime value for +// each proposed condition that is otherwise equivalent to the original value. +// This method is useful to prevent updating the status for a resource that is +// otherwise unchanged. +func (r *ResourceReconciler[T]) syncLastTransitionTime(proposed, original []metav1.Condition) { + for _, o := range original { + for i := range proposed { + p := &proposed[i] + if o.Type == p.Type { + if o.Status == p.Status && + o.Reason == p.Reason && + o.Message == p.Message && + o.ObservedGeneration == p.ObservedGeneration { + p.LastTransitionTime = o.LastTransitionTime + } + break + } + } + } +} diff --git a/reconcilers/resource_test.go b/reconcilers/resource_test.go new file mode 100644 index 0000000..495d5ea --- /dev/null +++ b/reconcilers/resource_test.go @@ -0,0 +1,798 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers_test + +import ( + "context" + "fmt" + "testing" + "time" + + diemetav1 "dies.dev/apis/meta/v1" + "github.com/google/go-cmp/cmp" + "github.com/vmware-labs/reconciler-runtime/apis" + "github.com/vmware-labs/reconciler-runtime/internal/resources" + "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" + "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtesting "github.com/vmware-labs/reconciler-runtime/testing" + corev1 "k8s.io/api/core/v1" + 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/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func TestResourceReconciler_NoStatus(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource-no-status" + testRequest := reconcilers.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, + } + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceNoStatusBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + d.AddAnnotation("blah", "blah") + }) + + rts := rtesting.ReconcilerTests{ + "resource exists": { + Request: testRequest, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNoStatus] { + return &reconcilers.SyncReconciler[*resources.TestResourceNoStatus]{ + Sync: func(ctx context.Context, resource *resources.TestResourceNoStatus) error { + return nil + }, + } + }, + }, + }, + } + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { + return &reconcilers.ResourceReconciler[*resources.TestResourceNoStatus]{ + Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNoStatus])(t, c), + Config: c, + } + }) +} + +func TestResourceReconciler_EmptyStatus(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource-empty-status" + testRequest := reconcilers.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, + } + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceEmptyStatusBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + d.AddAnnotation("blah", "blah") + }) + + rts := rtesting.ReconcilerTests{ + "resource exists": { + Request: testRequest, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceEmptyStatus] { + return &reconcilers.SyncReconciler[*resources.TestResourceEmptyStatus]{ + Sync: func(ctx context.Context, resource *resources.TestResourceEmptyStatus) error { + return nil + }, + } + }, + }, + }, + } + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { + return &reconcilers.ResourceReconciler[*resources.TestResourceEmptyStatus]{ + Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceEmptyStatus])(t, c), + Config: c, + } + }) +} + +func TestResourceReconciler_NilableStatus(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testRequest := reconcilers.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, + } + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceNilableStatusBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), + ) + }) + + rts := rtesting.ReconcilerTests{ + "nil status": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResourceNilableStatus{}, + }, + GivenObjects: []client.Object{ + resource.Status(nil), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus] { + return &reconcilers.SyncReconciler[*resources.TestResourceNilableStatus]{ + Sync: func(ctx context.Context, resource *resources.TestResourceNilableStatus) error { + if resource.Status != nil { + t.Errorf("status expected to be nil") + } + return nil + }, + } + }, + }, + }, + "status conditions are initialized": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResourceNilableStatus{}, + }, + GivenObjects: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus] { + return &reconcilers.SyncReconciler[*resources.TestResourceNilableStatus]{ + Sync: func(ctx context.Context, resource *resources.TestResourceNilableStatus) error { + expected := []metav1.Condition{ + {Type: apis.ConditionReady, Status: metav1.ConditionUnknown, Reason: "Initializing"}, + } + if diff := cmp.Diff(expected, resource.Status.Conditions, rtesting.IgnoreLastTransitionTime); diff != "" { + t.Errorf("Unexpected condition (-expected, +actual): %s", diff) + } + return nil + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", + `Updated status`), + }, + ExpectStatusUpdates: []client.Object{ + resource, + }, + }, + "reconciler mutated status": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResourceNilableStatus{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus] { + return &reconcilers.SyncReconciler[*resources.TestResourceNilableStatus]{ + Sync: func(ctx context.Context, resource *resources.TestResourceNilableStatus) error { + if resource.Status.Fields == nil { + resource.Status.Fields = map[string]string{} + } + resource.Status.Fields["Reconciler"] = "ran" + return nil + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", + `Updated status`), + }, + ExpectStatusUpdates: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("Reconciler", "ran") + }), + }, + }, + "status update failed": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResourceNilableStatus{}, + }, + GivenObjects: []client.Object{ + resource, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("update", "TestResourceNilableStatus", rtesting.InduceFailureOpts{ + SubResource: "status", + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus] { + return &reconcilers.SyncReconciler[*resources.TestResourceNilableStatus]{ + Sync: func(ctx context.Context, resource *resources.TestResourceNilableStatus) error { + if resource.Status.Fields == nil { + resource.Status.Fields = map[string]string{} + } + resource.Status.Fields["Reconciler"] = "ran" + return nil + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "StatusUpdateFailed", + `Failed to update status: inducing failure for update TestResourceNilableStatus`), + }, + ExpectStatusUpdates: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("Reconciler", "ran") + }), + }, + ShouldErr: true, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { + return &reconcilers.ResourceReconciler[*resources.TestResourceNilableStatus]{ + Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceNilableStatus])(t, c), + Config: c, + } + }) +} + +func TestResourceReconciler_Unstructured(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testRequest := reconcilers.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, + } + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + APIVersion(resources.GroupVersion.Identifier()). + Kind("TestResource"). + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + d.Generation(1) + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), + ) + }) + + rts := rtesting.ReconcilerTests{ + "in sync status": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return &reconcilers.SyncReconciler[*unstructured.Unstructured]{ + Sync: func(ctx context.Context, resource *unstructured.Unstructured) error { + return nil + }, + } + }, + }, + }, + "status update": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured] { + return &reconcilers.SyncReconciler[*unstructured.Unstructured]{ + Sync: func(ctx context.Context, resource *unstructured.Unstructured) error { + resource.Object["status"].(map[string]interface{})["fields"] = map[string]interface{}{ + "Reconciler": "ran", + } + return nil + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", `Updated status`), + }, + ExpectStatusUpdates: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("Reconciler", "ran") + }).DieReleaseUnstructured(), + }, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { + return &reconcilers.ResourceReconciler[*unstructured.Unstructured]{ + Type: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": resources.GroupVersion.Identifier(), + "kind": "TestResource", + }, + }, + Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured])(t, c), + Config: c, + } + }) +} + +func TestResourceReconciler(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testFinalizer := "test.finalizer" + testRequest := reconcilers.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, + } + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), + ) + }) + deletedAt := metav1.NewTime(time.UnixMilli(2000)) + + rts := rtesting.ReconcilerTests{ + "resource does not exist": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + t.Error("should not be called") + return nil + }, + } + }, + }, + }, + "ignore deleted resource": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource.MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&deletedAt) + d.Finalizers(testFinalizer) + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + t.Error("should not be called") + return nil + }, + } + }, + }, + }, + "error fetching resource": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("get", "TestResource"), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + t.Error("should not be called") + return nil + }, + } + }, + }, + ShouldErr: true, + }, + "resource is defaulted": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + if expected, actual := "ran", resource.Spec.Fields["Defaulter"]; expected != actual { + t.Errorf("unexpected default value, actually = %v, expected = %v", expected, actual) + } + return nil + }, + } + }, + }, + }, + "status conditions are initialized": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie() + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + expected := []metav1.Condition{ + {Type: apis.ConditionReady, Status: metav1.ConditionUnknown, Reason: "Initializing"}, + } + if diff := cmp.Diff(expected, resource.Status.Conditions, rtesting.IgnoreLastTransitionTime); diff != "" { + t.Errorf("Unexpected condition (-expected, +actual): %s", diff) + } + return nil + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", + `Updated status`), + }, + ExpectStatusUpdates: []client.Object{ + resource, + }, + }, + "reconciler mutated status": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + if resource.Status.Fields == nil { + resource.Status.Fields = map[string]string{} + } + resource.Status.Fields["Reconciler"] = "ran" + return nil + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", + `Updated status`), + }, + ExpectStatusUpdates: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("Reconciler", "ran") + }), + }, + }, + "skip status updates": { + Request: testRequest, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SkipStatusUpdate": true, + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + if resource.Status.Fields == nil { + resource.Status.Fields = map[string]string{} + } + resource.Status.Fields["Reconciler"] = "ran" + return nil + }, + } + }, + }, + }, + "sub reconciler erred": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + return fmt.Errorf("reconciler error") + }, + } + }, + }, + ShouldErr: true, + }, + "sub reconciler halted": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + resource.Status.Fields = map[string]string{ + "want": "this to run", + } + return reconcilers.HaltSubReconcilers + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + resource.Status.Fields = map[string]string{ + "don't want": "this to run", + } + return fmt.Errorf("reconciler error") + }, + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", + `Updated status`), + }, + ExpectStatusUpdates: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("want", "this to run") + }), + }, + }, + "sub reconciler halted with result": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + resource.Status.Fields = map[string]string{ + "want": "this to run", + } + return reconcilers.Result{Requeue: true}, reconcilers.HaltSubReconcilers + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + resource.Status.Fields = map[string]string{ + "don't want": "this to run", + } + return fmt.Errorf("reconciler error") + }, + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", + `Updated status`), + }, + ExpectStatusUpdates: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("want", "this to run") + }), + }, + ExpectedResult: reconcilers.Result{Requeue: true}, + }, + "status update failed": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("update", "TestResource", rtesting.InduceFailureOpts{ + SubResource: "status", + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + if resource.Status.Fields == nil { + resource.Status.Fields = map[string]string{} + } + resource.Status.Fields["Reconciler"] = "ran" + return nil + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "StatusUpdateFailed", + `Failed to update status: inducing failure for update TestResource`), + }, + ExpectStatusUpdates: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("Reconciler", "ran") + }), + }, + ShouldErr: true, + }, + "context is stashable": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + var key reconcilers.StashKey = "foo" + // StashValue will panic if the context is not setup correctly + reconcilers.StashValue(ctx, key, "bar") + return nil + }, + } + }, + }, + }, + "context has config": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + if config := reconcilers.RetrieveConfigOrDie(ctx); config != c { + t.Errorf("expected config in context, found %#v", config) + } + if resourceConfig := reconcilers.RetrieveOriginalConfigOrDie(ctx); resourceConfig != c { + t.Errorf("expected original config in context, found %#v", resourceConfig) + } + return nil + }, + } + }, + }, + }, + "context has resource type": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + if resourceType, ok := reconcilers.RetrieveOriginalResourceType(ctx).(*resources.TestResource); !ok { + t.Errorf("expected original resource type not in context, found %#v", resourceType) + } + if resourceType, ok := reconcilers.RetrieveResourceType(ctx).(*resources.TestResource); !ok { + t.Errorf("expected resource type not in context, found %#v", resourceType) + } + return nil + }, + } + }, + }, + }, + "context can be augmented in Prepare and accessed in Cleanup": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Prepare: func(t *testing.T, ctx context.Context, tc *rtesting.ReconcilerTestCase) (context.Context, error) { + key := "test-key" + value := "test-value" + ctx = context.WithValue(ctx, key, value) + + tc.Metadata["SubReconciler"] = func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + if v := ctx.Value(key); v != value { + t.Errorf("expected %s to be in context", key) + } + return nil + }, + } + } + tc.CleanUp = func(t *testing.T, ctx context.Context, tc *rtesting.ReconcilerTestCase) error { + if v := ctx.Value(key); v != value { + t.Errorf("expected %s to be in context", key) + } + return nil + } + + return ctx, nil + }, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { + skipStatusUpdate := false + if skip, ok := rtc.Metadata["SkipStatusUpdate"].(bool); ok { + skipStatusUpdate = skip + } + return &reconcilers.ResourceReconciler[*resources.TestResource]{ + Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c), + SkipStatusUpdate: skipStatusUpdate, + Config: c, + } + }) +} diff --git a/reconcilers/resourcemanager.go b/reconcilers/resourcemanager.go new file mode 100644 index 0000000..0414c8c --- /dev/null +++ b/reconcilers/resourcemanager.go @@ -0,0 +1,282 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + jsonpatch "github.com/evanphx/json-patch/v5" + "github.com/go-logr/logr" + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/util/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/vmware-labs/reconciler-runtime/internal" +) + +// ResourceManager compares the actual and desired resources to create/update/delete as desired. +type ResourceManager[Type client.Object] struct { + // Name used to identify this reconciler. Defaults to `{Type}ResourceManager`. Ideally + // unique, but not required to be so. + // + // +optional + Name string + + // Type is the resource being created/updated/deleted by the reconciler. Required when the + // generic type is not a struct, or is unstructured. + // + // +optional + Type Type + + // Finalizer is set on the reconciled resource before a managed resource is created, and cleared + // after a managed resource is deleted. The value must be unique to this specific manager + // instance and not shared. Reusing a value may result in orphaned resources when the + // reconciled resource is deleted. + // + // Using a finalizer is encouraged when the Kubernetes garbage collector is unable to delete + // the child resource automatically, like when the reconciled resource and child are in different + // namespaces, scopes or clusters. + // + // +optional + Finalizer string + + // TrackDesired when true, the desired resource is tracked after creates, before + // updates, and on delete errors. + TrackDesired bool + + // HarmonizeImmutableFields allows fields that are immutable on the current + // object to be copied to the desired object in order to avoid creating + // updates which are guaranteed to fail. + // + // +optional + HarmonizeImmutableFields func(current, desired Type) + + // MergeBeforeUpdate copies desired fields on to the current object before + // calling update. Typically fields to copy are the Spec, Labels and + // Annotations. + MergeBeforeUpdate func(current, desired Type) + + // Sanitize is called with an object before logging the value. Any value may + // be returned. A meaningful subset of the resource is typically returned, + // like the Spec. + // + // +optional + Sanitize func(child Type) interface{} + + // mutationCache holds patches received from updates to a resource made by + // mutation webhooks. This cache is used to avoid unnecessary update calls + // that would actually have no effect. + mutationCache *cache.Expiring + lazyInit sync.Once +} + +func (r *ResourceManager[T]) init() { + r.lazyInit.Do(func() { + if internal.IsNil(r.Type) { + var nilT T + r.Type = newEmpty(nilT).(T) + } + if r.Name == "" { + r.Name = fmt.Sprintf("%sResourceManager", typeName(r.Type)) + } + r.mutationCache = cache.NewExpiring() + }) +} + +func (r *ResourceManager[T]) Setup(ctx context.Context) error { + r.init() + return r.validate(ctx) +} + +func (r *ResourceManager[T]) validate(ctx context.Context) error { + // require MergeBeforeUpdate + if r.MergeBeforeUpdate == nil { + return fmt.Errorf("ResourceManager %q must define MergeBeforeUpdate", r.Name) + } + + return nil +} + +// Manage a specific resource to create/update/delete based on the actual and desired state. The +// resource is the reconciled resource and used to record events for mutations. The actual and +// desired objects represent the managed resource and must be compatible with the type field. +func (r *ResourceManager[T]) Manage(ctx context.Context, resource client.Object, actual, desired T) (T, error) { + r.init() + + var nilT T + + log := logr.FromContextOrDiscard(ctx) + pc := RetrieveOriginalConfigOrDie(ctx) + c := RetrieveConfigOrDie(ctx) + + if (internal.IsNil(actual) || actual.GetCreationTimestamp().Time.IsZero()) && internal.IsNil(desired) { + if err := ClearFinalizer(ctx, resource, r.Finalizer); err != nil { + return nilT, err + } + return nilT, nil + } + + // delete resource if no longer needed + if internal.IsNil(desired) { + if !actual.GetCreationTimestamp().Time.IsZero() && actual.GetDeletionTimestamp() == nil { + log.Info("deleting unwanted resource", "resource", namespaceName(actual)) + if err := c.Delete(ctx, actual); err != nil { + log.Error(err, "unable to delete unwanted resource", "resource", namespaceName(actual)) + pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "DeleteFailed", + "Failed to delete %s %q: %v", typeName(actual), actual.GetName(), err) + return nilT, err + } + pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Deleted", + "Deleted %s %q", typeName(actual), actual.GetName()) + + } + return nilT, nil + } + + if err := AddFinalizer(ctx, resource, r.Finalizer); err != nil { + return nilT, err + } + + // create resource if it doesn't exist + if internal.IsNil(actual) || actual.GetCreationTimestamp().Time.IsZero() { + log.Info("creating resource", "resource", r.sanitize(desired)) + if err := c.Create(ctx, desired); err != nil { + log.Error(err, "unable to create resource", "resource", namespaceName(desired)) + pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "CreationFailed", + "Failed to create %s %q: %v", typeName(desired), desired.GetName(), err) + return nilT, err + } + if r.TrackDesired { + // normally tracks should occur before API operations, but when creating a resource with a + // generated name, we need to know the actual resource name. + + if err := c.Tracker.TrackObject(desired, resource); err != nil { + return nilT, err + } + } + pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Created", + "Created %s %q", typeName(desired), desired.GetName()) + return desired, nil + } + + // overwrite fields that should not be mutated + if r.HarmonizeImmutableFields != nil { + r.HarmonizeImmutableFields(actual, desired) + } + + // lookup and apply remote mutations + desiredPatched := desired.DeepCopyObject().(T) + if patch, ok := r.mutationCache.Get(actual.GetUID()); ok { + // the only object added to the cache is *Patch + err := patch.(*Patch).Apply(desiredPatched) + if err != nil { + // there's not much we can do, but let the normal update proceed + log.Info("unable to patch desired child from mutation cache") + } + } + + // update resource with desired changes + current := actual.DeepCopyObject().(T) + r.MergeBeforeUpdate(current, desiredPatched) + if equality.Semantic.DeepEqual(current, actual) { + // resource is unchanged + log.Info("resource is in sync, no update required") + return actual, nil + } + log.Info("updating resource", "diff", cmp.Diff(r.sanitize(actual), r.sanitize(current))) + if r.TrackDesired { + if err := c.Tracker.TrackObject(current, resource); err != nil { + return nilT, err + } + } + if err := c.Update(ctx, current); err != nil { + log.Error(err, "unable to update resource", "resource", namespaceName(current)) + pc.Recorder.Eventf(resource, corev1.EventTypeWarning, "UpdateFailed", + "Failed to update %s %q: %v", typeName(current), current.GetName(), err) + return nilT, err + } + + // capture admission mutation patch + base := current.DeepCopyObject().(T) + r.MergeBeforeUpdate(base, desired) + patch, err := NewPatch(base, current) + if err != nil { + log.Error(err, "unable to generate mutation patch", "snapshot", r.sanitize(desired), "base", r.sanitize(base)) + } else { + r.mutationCache.Set(current.GetUID(), patch, 1*time.Hour) + } + + log.Info("updated resource") + pc.Recorder.Eventf(resource, corev1.EventTypeNormal, "Updated", + "Updated %s %q", typeName(current), current.GetName()) + + return current, nil +} + +func (r *ResourceManager[T]) sanitize(resource T) interface{} { + if r.Sanitize == nil { + return resource + } + if internal.IsNil(resource) { + return nil + } + + // avoid accidental mutations in Sanitize method + resource = resource.DeepCopyObject().(T) + return r.Sanitize(resource) +} + +func NewPatch(base, update client.Object) (*Patch, error) { + baseBytes, err := json.Marshal(base) + if err != nil { + return nil, err + } + updateBytes, err := json.Marshal(update) + if err != nil { + return nil, err + } + patch, err := jsonpatch.CreateMergePatch(baseBytes, updateBytes) + if err != nil { + return nil, err + } + + return &Patch{ + generation: base.GetGeneration(), + bytes: patch, + }, nil +} + +type Patch struct { + generation int64 + bytes []byte +} + +var PatchGenerationMismatch = errors.New("patch generation did not match target") + +func (p *Patch) Apply(rebase client.Object) error { + if rebase.GetGeneration() != p.generation { + return PatchGenerationMismatch + } + + rebaseBytes, err := json.Marshal(rebase) + if err != nil { + return err + } + patchedBytes, err := jsonpatch.MergePatch(rebaseBytes, p.bytes) + if err != nil { + return err + } + // reset rebase to its empty value before unmarshaling into it + replaceWithEmpty(rebase) + return json.Unmarshal(patchedBytes, rebase) +} diff --git a/reconcilers/patch_test.go b/reconcilers/resourcemanager_test.go similarity index 100% rename from reconcilers/patch_test.go rename to reconcilers/resourcemanager_test.go diff --git a/reconcilers/sequence.go b/reconcilers/sequence.go new file mode 100644 index 0000000..25bbadd --- /dev/null +++ b/reconcilers/sequence.go @@ -0,0 +1,55 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + _ SubReconciler[client.Object] = (Sequence[client.Object])(nil) +) + +// Sequence is a collection of SubReconcilers called in order. If a +// reconciler errs, further reconcilers are skipped. +type Sequence[Type client.Object] []SubReconciler[Type] + +func (r Sequence[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { + for i, reconciler := range r { + log := logr.FromContextOrDiscard(ctx). + WithName(fmt.Sprintf("%d", i)) + ctx = logr.NewContext(ctx, log) + + err := reconciler.SetupWithManager(ctx, mgr, bldr) + if err != nil { + return err + } + } + return nil +} + +func (r Sequence[T]) Reconcile(ctx context.Context, resource T) (Result, error) { + aggregateResult := Result{} + for i, reconciler := range r { + log := logr.FromContextOrDiscard(ctx). + WithName(fmt.Sprintf("%d", i)) + ctx = logr.NewContext(ctx, log) + + result, err := reconciler.Reconcile(ctx, resource) + aggregateResult = AggregateResults(result, aggregateResult) + if err != nil { + return result, err + } + } + + return aggregateResult, nil +} diff --git a/reconcilers/sequence_test.go b/reconcilers/sequence_test.go new file mode 100644 index 0000000..799fd3c --- /dev/null +++ b/reconcilers/sequence_test.go @@ -0,0 +1,287 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers_test + +import ( + "context" + "fmt" + "testing" + "time" + + diemetav1 "dies.dev/apis/meta/v1" + "github.com/vmware-labs/reconciler-runtime/apis" + "github.com/vmware-labs/reconciler-runtime/internal/resources" + "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" + "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtesting "github.com/vmware-labs/reconciler-runtime/testing" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestSequence(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), + ) + }) + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "sub reconciler erred": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + return fmt.Errorf("reconciler error") + }, + }, + } + }, + }, + ShouldErr: true, + }, + "preserves result, sub reconciler halted": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil + }, + }, &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 2 * time.Minute}, reconcilers.HaltSubReconcilers + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, + ShouldErr: true, + }, + "preserves result, Requeue": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, nil + }, + } + }, + }, + ExpectedResult: reconcilers.Result{Requeue: true}, + }, + "preserves result, RequeueAfter": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, + }, + "ignores result on err": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, fmt.Errorf("test error") + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{}, + ShouldErr: true, + }, + "Requeue + empty => Requeue": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, nil + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{Requeue: true}, + }, + "empty + Requeue => Requeue": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{}, nil + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{Requeue: true}, + }, + "RequeueAfter + empty => RequeueAfter": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, + }, + "empty + RequeueAfter => RequeueAfter": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{}, nil + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, + }, + "RequeueAfter + Requeue => RequeueAfter": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, + }, + "Requeue + RequeueAfter => RequeueAfter": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, nil + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, + }, + "RequeueAfter(1m) + RequeueAfter(2m) => RequeueAfter(1m)": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 2 * time.Minute}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, + }, + "RequeueAfter(2m) + RequeueAfter(1m) => RequeueAfter(1m)": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return reconcilers.Sequence[*resources.TestResource]{ + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 2 * time.Minute}, nil + }, + }, + &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 1 * time.Minute}, nil + }, + }, + } + }, + }, + ExpectedResult: reconcilers.Result{RequeueAfter: 1 * time.Minute}, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) + }) +} diff --git a/reconcilers/sync.go b/reconcilers/sync.go new file mode 100644 index 0000000..ac7e6d2 --- /dev/null +++ b/reconcilers/sync.go @@ -0,0 +1,156 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + _ SubReconciler[client.Object] = (*SyncReconciler[client.Object])(nil) +) + +// SyncReconciler is a sub reconciler for custom reconciliation logic. No +// behavior is defined directly. +type SyncReconciler[Type client.Object] struct { + // Name used to identify this reconciler. Defaults to `SyncReconciler`. Ideally unique, but + // not required to be so. + // + // +optional + Name string + + // Setup performs initialization on the manager and builder this reconciler + // will run with. It's common to setup field indexes and watch resources. + // + // +optional + Setup func(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error + + // SyncDuringFinalization indicates the Sync method should be called when the resource is pending deletion. + SyncDuringFinalization bool + + // Sync does whatever work is necessary for the reconciler. + // + // If SyncDuringFinalization is true this method is called when the resource is pending + // deletion. This is useful if the reconciler is managing reference data. + // + // Mutually exclusive with SyncWithResult + Sync func(ctx context.Context, resource Type) error + + // SyncWithResult does whatever work is necessary for the reconciler. + // + // If SyncDuringFinalization is true this method is called when the resource is pending + // deletion. This is useful if the reconciler is managing reference data. + // + // Mutually exclusive with Sync + SyncWithResult func(ctx context.Context, resource Type) (Result, error) + + // Finalize does whatever work is necessary for the reconciler when the resource is pending + // deletion. If this reconciler sets a finalizer it should do the necessary work to clean up + // state the finalizer represents and then clear the finalizer. + // + // Mutually exclusive with FinalizeWithResult + // + // +optional + Finalize func(ctx context.Context, resource Type) error + + // Finalize does whatever work is necessary for the reconciler when the resource is pending + // deletion. If this reconciler sets a finalizer it should do the necessary work to clean up + // state the finalizer represents and then clear the finalizer. + // + // Mutually exclusive with Finalize + // + // +optional + FinalizeWithResult func(ctx context.Context, resource Type) (Result, error) +} + +func (r *SyncReconciler[T]) SetupWithManager(ctx context.Context, mgr ctrl.Manager, bldr *builder.Builder) error { + if r.Name == "" { + r.Name = "SyncReconciler" + } + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name) + ctx = logr.NewContext(ctx, log) + + if r.Setup == nil { + return nil + } + if err := r.validate(ctx); err != nil { + return err + } + return r.Setup(ctx, mgr, bldr) +} + +func (r *SyncReconciler[T]) validate(ctx context.Context) error { + // validate Sync and SyncWithResult + if r.Sync == nil && r.SyncWithResult == nil { + return fmt.Errorf("SyncReconciler %q must implement Sync or SyncWithResult", r.Name) + } + if r.Sync != nil && r.SyncWithResult != nil { + return fmt.Errorf("SyncReconciler %q may not implement both Sync and SyncWithResult", r.Name) + } + + // validate Finalize and FinalizeWithResult + if r.Finalize != nil && r.FinalizeWithResult != nil { + return fmt.Errorf("SyncReconciler %q may not implement both Finalize and FinalizeWithResult", r.Name) + } + + return nil +} + +func (r *SyncReconciler[T]) Reconcile(ctx context.Context, resource T) (Result, error) { + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name) + ctx = logr.NewContext(ctx, log) + + result := Result{} + + if resource.GetDeletionTimestamp() == nil || r.SyncDuringFinalization { + syncResult, err := r.sync(ctx, resource) + result = AggregateResults(result, syncResult) + if err != nil { + log.Error(err, "unable to sync") + return result, err + } + } + + if resource.GetDeletionTimestamp() != nil { + finalizeResult, err := r.finalize(ctx, resource) + result = AggregateResults(result, finalizeResult) + if err != nil { + log.Error(err, "unable to finalize") + return result, err + } + } + + return result, nil +} + +func (r *SyncReconciler[T]) sync(ctx context.Context, resource T) (Result, error) { + if r.Sync != nil { + err := r.Sync(ctx, resource) + return Result{}, err + } + return r.SyncWithResult(ctx, resource) +} + +func (r *SyncReconciler[T]) finalize(ctx context.Context, resource T) (Result, error) { + if r.Finalize != nil { + err := r.Finalize(ctx, resource) + return Result{}, err + } + if r.FinalizeWithResult != nil { + return r.FinalizeWithResult(ctx, resource) + } + + return Result{}, nil +} diff --git a/reconcilers/sync_test.go b/reconcilers/sync_test.go new file mode 100644 index 0000000..e495dae --- /dev/null +++ b/reconcilers/sync_test.go @@ -0,0 +1,273 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers_test + +import ( + "context" + "fmt" + "testing" + "time" + + diemetav1 "dies.dev/apis/meta/v1" + "github.com/vmware-labs/reconciler-runtime/apis" + "github.com/vmware-labs/reconciler-runtime/internal/resources" + "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" + "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtesting "github.com/vmware-labs/reconciler-runtime/testing" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +func TestSyncReconciler(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testFinalizer := "test.finalizer" + + now := metav1.Now() + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), + ) + }) + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "sync success": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + return nil + }, + } + }, + }, + }, + "sync with result halted": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{Requeue: true}, reconcilers.HaltSubReconcilers + }, + } + }, + }, + ExpectedResult: reconcilers.Result{Requeue: true}, + ShouldErr: true, + }, + "sync error": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + return fmt.Errorf("syncreconciler error") + }, + } + }, + }, + ShouldErr: true, + }, + "missing sync method": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: nil, + } + }, + }, + ShouldPanic: true, + }, + "should not finalize non-deleted resources": { + Resource: resource.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil + }, + FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + t.Errorf("reconciler should not call finalize for non-deleted resources") + return reconcilers.Result{RequeueAfter: 3 * time.Hour}, nil + }, + } + }, + }, + ExpectedResult: reconcile.Result{RequeueAfter: 2 * time.Hour}, + }, + "should finalize deleted resources": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + t.Errorf("reconciler should not call sync for deleted resources") + return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil + }, + FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 3 * time.Hour}, nil + }, + } + }, + }, + ExpectedResult: reconcile.Result{RequeueAfter: 3 * time.Hour}, + }, + "finalize can halt subreconcilers": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + t.Errorf("reconciler should not call sync for deleted resources") + return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil + }, + FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 3 * time.Hour}, reconcilers.HaltSubReconcilers + }, + } + }, + }, + ExpectedResult: reconcile.Result{RequeueAfter: 3 * time.Hour}, + ShouldErr: true, + }, + "should finalize and sync deleted resources when asked to": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncDuringFinalization: true, + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil + }, + FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 3 * time.Hour}, nil + }, + } + }, + }, + ExpectedResult: reconcile.Result{RequeueAfter: 2 * time.Hour}, + }, + "should finalize and sync deleted resources when asked to, shorter resync wins": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + SyncDuringFinalization: true, + SyncWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 3 * time.Hour}, nil + }, + FinalizeWithResult: func(ctx context.Context, resource *resources.TestResource) (reconcilers.Result, error) { + return reconcilers.Result{RequeueAfter: 2 * time.Hour}, nil + }, + } + }, + }, + ExpectedResult: reconcile.Result{RequeueAfter: 2 * time.Hour}, + }, + "finalize is optional": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + return nil + }, + } + }, + }, + }, + "finalize error": { + Resource: resource. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.DeletionTimestamp(&now) + d.Finalizers(testFinalizer) + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + return nil + }, + Finalize: func(ctx context.Context, resource *resources.TestResource) error { + return fmt.Errorf("syncreconciler finalize error") + }, + } + }, + }, + ShouldErr: true, + }, + "context can be augmented in Prepare and accessed in Cleanup": { + Resource: resource.DieReleasePtr(), + Prepare: func(t *testing.T, ctx context.Context, tc *rtesting.SubReconcilerTestCase[*resources.TestResource]) (context.Context, error) { + key := "test-key" + value := "test-value" + ctx = context.WithValue(ctx, key, value) + + tc.Metadata["SubReconciler"] = func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return &reconcilers.SyncReconciler[*resources.TestResource]{ + Sync: func(ctx context.Context, resource *resources.TestResource) error { + if v := ctx.Value(key); v != value { + t.Errorf("expected %s to be in context", key) + } + return nil + }, + } + } + tc.CleanUp = func(t *testing.T, ctx context.Context, tc *rtesting.SubReconcilerTestCase[*resources.TestResource]) error { + if v := ctx.Value(key); v != value { + t.Errorf("expected %s to be in context", key) + } + return nil + } + + return ctx, nil + }, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.SubReconcilerTestCase[*resources.TestResource], c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource])(t, c) + }) +} diff --git a/reconcilers/reconcilers_validate_test.go b/reconcilers/validate_test.go similarity index 100% rename from reconcilers/reconcilers_validate_test.go rename to reconcilers/validate_test.go