diff --git a/README.md b/README.md index 7b1547e..fac2dcb 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Within an existing Kubebuilder or controller-runtime project, reconciler-runtime - [SubReconciler](#subreconciler) - [SyncReconciler](#syncreconciler) - [ChildReconciler](#childreconciler) + - [ChildSetReconciler](#childsetreconciler) - [Higher-order Reconcilers](#higher-order-reconcilers) - [CastResource](#castresource) - [Sequence](#sequence) @@ -235,7 +236,7 @@ func FunctionTargetImageReconciler(c reconcilers.Config) reconcilers.SubReconcil The [`ChildReconciler`](https://pkg.go.dev/github.com/vmware-labs/reconciler-runtime/reconcilers#ChildReconciler) is a sub reconciler that is responsible for managing a single controlled resource. Within a child reconciler, the reconciled resource is referred to as the parent resource to avoid ambiguity with the child resource. A developer defines their desired state for the child resource (if any), and the reconciler creates/updates/deletes the resource to match the desired state. The child resource is also used to update the parent's status. Mutations and errors are recorded for the parent. -The ChildReconciler is responsible for: +The `ChildReconciler` is responsible for: - looking up an existing child - creating/updating/deleting the child resource based on the desired state - setting the owner reference on the child resource (when not using a finalizer) @@ -253,9 +254,9 @@ The implementor is responsible for: When a finalizer is defined, the parent resource is patched to add the finalizer before creating the child; it is removed after the child is deleted. If the parent resource is pending deletion, the desired child method is not called, and existing children are deleted. -Using a finalizer means that the child resource will not use an owner reference. The OurChild method must be implemented in a way that can uniquely and unambiguously identify the child that this parent resource is responsible for from any other resources of the same kind. The child resource is tracked explicitly. +Using a finalizer means that the child resource will not use an owner reference. The `OurChild` method must be implemented in a way that can uniquely and unambiguously identify the child that this parent resource is responsible for from any other resources of the same kind. The child resource is tracked explicitly to watch for mutations triggering the parent resource to be reconciled. -> Warning: It is crucial that each ChildReconciler using a finalizer have a unique and stable finalizer name. Two reconcilers that use the same finalizer, or a reconciler that changed the name of its finalizer, may leak the child resource when the parent is deleted, or the parent resource may never terminate. +> Warning: It is crucial that each `ChildReconciler` using a finalizer have a unique and stable finalizer name. Two reconcilers that use the same finalizer, or a reconciler that changed the name of its finalizer, may leak the child resource when the parent is deleted, or the parent resource may never terminate. **Example:** @@ -347,6 +348,38 @@ rules: verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] ``` +#### ChildSetReconciler + +The [`ChildSetReconciler`](https://pkg.go.dev/github.com/vmware-labs/reconciler-runtime/reconcilers#ChildSetReconciler) is an orchestration of zero to many, dynamically defined [`ChildReconcilers`](#childreconciler). Concepts from `ChildReconciler` apply here unless noted otherwise. + +Unlike `ChildReconciler` where a single desired child is defined, the `ChildSetReconciler` returns zero to many desired children, each child resource must contain a stable identifier extracted from the child resource by `IdentifyChild`, which is used to correlate desired children with actual children within the cluster. + +Based on the combined set of identifiers for desired and actual children, a `ChildReconciler` is created for each identifier. Each `ChildReconciler` is reconciled in order, sorted by the identifier. The result from each `ChildReconciler` are aggregated and presented at once to be reflected onto the reconciled resource's status within `ReflectChildrenStatusOnParent`. + +As there is some overhead in the dynamic creation of reconcilers. When the number of children is limited and known in advance, it is preferable to statically construct many `ChildReconciler`. + +When a finalizer is defined, the dynamic reconciler is wrapped with [`WithFinalizer`](#withfinalizer). Using a finalizer means that the child resource will not use an owner reference. The `OurChild` method must be implemented in a way that can uniquely and unambiguously identify the children that this parent resource is responsible for from any other resources of the same kind. The child resources are tracked explicitly to watch for mutations triggering the parent resource to be reconciled. + +**Recommended RBAC:** + +Replace `` and `` with values for the child type. + +```go +// +kubebuilder:rbac:groups=,resources=,verbs=get;list;watch;create;update;patch;delete +``` + +or + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: # any name that is bound to the ServiceAccount used by the client +rules: +- apiGroups: [""] + resources: [""] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +``` ### Higher-order Reconcilers diff --git a/reconcilers/childset.go b/reconcilers/childset.go new file mode 100644 index 0000000..d918c68 --- /dev/null +++ b/reconcilers/childset.go @@ -0,0 +1,365 @@ +/* +Copyright 2023 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/go-logr/logr" + "github.com/vmware-labs/reconciler-runtime/internal" + utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/sets" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ChildSetReconciler is a sub reconciler that manages a set of child resources for a reconciled +// resource. A correlation ID is used to track the desired state of each child resource across +// reconcile requests. A ChildReconciler is created dynamically and reconciled for each desired +// and discovered child resource. +// +// During setup, the child resource type is registered to watch for changes. +type ChildSetReconciler[Type, ChildType client.Object, ChildListType client.ObjectList] struct { + // Name used to identify this reconciler. Defaults to `{ChildType}ChildSetReconciler`. 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. + // + // +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 + + // DesiredChildren returns the set of desired child object for the given reconciled resource, + // or nil if no children should exist. Each resource returned from this method must be claimed + // by the OurChild method with a stable, unique identifier returned. The identifier is used to + // correlate desired and actual child resources. + // + // To skip reconciliation of the child resources while still reflecting an existing child's + // status on the reconciled resource, return OnlyReconcileChildStatus as an error. + DesiredChildren func(ctx context.Context, resource Type) ([]ChildType, error) + + // ReflectChildrenStatusOnParent updates the reconciled resource's status with values from the + // child reconciliations. Select types of errors are captured, 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. + ReflectChildrenStatusOnParent func(ctx context.Context, parent Type, result ChildSetResult[ChildType]) + + // 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 sources of children of the same ChildType + // controlled by the same reconciled resource. The function return true for child resources + // managed by this ChildReconciler. Objects returned from the DesiredChildren function should + // match this function, otherwise they may be orphaned. If not specified, all children match. + // Matched child resources must also be uniquely identifiable with the IdentifyChild method. + // + // OurChild is required when a Finalizer is defined or SkipOwnerReference is true. + // + // +optional + OurChild func(resource Type, child ChildType) bool + + // IdentifyChild returns a stable identifier for the child resource. The identifier is used to + // correlate desired child resources with actual child resources. The same value must be returned + // for an object both before and after it is created on the API server. + // + // Non-deterministic IDs will result in the rapid deletion and creation of child resources. + IdentifyChild func(child ChildType) string + + // 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 + voidReconciler *ChildReconciler[Type, ChildType, ChildListType] +} + +func (r *ChildSetReconciler[T, CT, CLT]) init() { + r.lazyInit.Do(func() { + var nilCT CT + if internal.IsNil(r.ChildType) { + 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("%sChildSetReconciler", typeName(r.ChildType)) + } + r.stamp = &ResourceManager[CT]{ + Name: r.Name, + Type: r.ChildType, + TrackDesired: r.SkipOwnerReference, + HarmonizeImmutableFields: r.HarmonizeImmutableFields, + MergeBeforeUpdate: r.MergeBeforeUpdate, + Sanitize: r.Sanitize, + } + r.voidReconciler = r.childReconcilerFor(nilCT, nil, "", true) + }) +} + +func (r *ChildSetReconciler[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 err := r.voidReconciler.SetupWithManager(ctx, mgr, bldr); err != nil { + return err + } + + if r.Setup == nil { + return nil + } + return r.Setup(ctx, mgr, bldr) +} + +func (r *ChildSetReconciler[T, CT, CLT]) childReconcilerFor(desired CT, desiredErr error, id string, void bool) *ChildReconciler[T, CT, CLT] { + return &ChildReconciler[T, CT, CLT]{ + Name: id, + ChildType: r.ChildType, + ChildListType: r.ChildListType, + SkipOwnerReference: r.SkipOwnerReference, + DesiredChild: func(ctx context.Context, resource T) (CT, error) { + return desired, desiredErr + }, + ReflectChildStatusOnParent: func(ctx context.Context, parent T, child CT, err error) { + result := retrieveChildSetResult[CT](ctx) + result.Children = append(result.Children, ChildSetPartialResult[CT]{ + Id: id, + Child: child, + Err: err, + }) + stashChildSetResult(ctx, result) + }, + HarmonizeImmutableFields: r.HarmonizeImmutableFields, + MergeBeforeUpdate: r.MergeBeforeUpdate, + ListOptions: r.ListOptions, + OurChild: func(resource T, child CT) bool { + if r.OurChild != nil && !r.OurChild(resource, child) { + return false + } + return void || id == r.IdentifyChild(child) + }, + Sanitize: r.Sanitize, + } +} + +func (r *ChildSetReconciler[T, CT, CLT]) validate(ctx context.Context) error { + // default implicit values + if r.Finalizer != "" { + r.SkipOwnerReference = true + } + + // require DesiredChildren + if r.DesiredChildren == nil { + return fmt.Errorf("ChildSetReconciler %q must implement DesiredChildren", r.Name) + } + + // require ReflectChildrenStatusOnParent + if r.ReflectChildrenStatusOnParent == nil { + return fmt.Errorf("ChildSetReconciler %q must implement ReflectChildrenStatusOnParent", r.Name) + } + + if r.OurChild == nil && r.SkipOwnerReference { + // OurChild is required when SkipOwnerReference is true + return fmt.Errorf("ChildSetReconciler %q must implement OurChild since owner references are not used", r.Name) + } + + // require IdentifyChild + if r.IdentifyChild == nil { + return fmt.Errorf("ChildSetReconciler %q must implement IdentifyChild", r.Name) + } + + return nil +} + +func (r *ChildSetReconciler[T, CT, CLT]) Reconcile(ctx context.Context, resource T) (Result, error) { + r.init() + + log := logr.FromContextOrDiscard(ctx). + WithName(r.Name) + ctx = logr.NewContext(ctx, log) + + cr, err := r.composeChildReconcilers(ctx, resource) + if err != nil { + return Result{}, err + } + result, err := cr.Reconcile(ctx, resource) + r.reflectStatus(ctx, resource) + return result, err +} + +func (r *ChildSetReconciler[T, CT, CLT]) composeChildReconcilers(ctx context.Context, resource T) (SubReconciler[T], error) { + c := RetrieveConfigOrDie(ctx) + + childIDs := sets.NewString() + desiredChildren, desiredChildrenErr := r.DesiredChildren(ctx, resource) + if desiredChildrenErr != nil && !errors.Is(desiredChildrenErr, OnlyReconcileChildStatus) { + return nil, desiredChildrenErr + } + + desiredChildByID := map[string]CT{} + for _, child := range desiredChildren { + id := r.IdentifyChild(child) + if id == "" { + return nil, fmt.Errorf("desired child id may not be empty") + } + if childIDs.Has(id) { + return nil, fmt.Errorf("duplicate child id found: %s", id) + } + childIDs.Insert(id) + desiredChildByID[id] = child + } + + children := r.ChildListType.DeepCopyObject().(CLT) + if err := c.List(ctx, children, r.voidReconciler.listOptions(ctx, resource)...); err != nil { + return nil, err + } + for _, child := range extractItems[CT](children) { + if !r.voidReconciler.ourChild(resource, child) { + continue + } + id := r.IdentifyChild(child) + childIDs.Insert(id) + } + + sequence := Sequence[T]{} + for _, id := range childIDs.List() { + child := desiredChildByID[id] + cr := r.childReconcilerFor(child, desiredChildrenErr, id, false) + cr.SetResourceManager(r.stamp) + sequence = append(sequence, cr) + } + + if r.Finalizer != "" { + return &WithFinalizer[T]{ + Finalizer: r.Finalizer, + Reconciler: sequence, + }, nil + } + return sequence, nil +} + +func (r *ChildSetReconciler[T, CT, CLT]) reflectStatus(ctx context.Context, parent T) { + result := clearChildSetResult[CT](ctx) + r.ReflectChildrenStatusOnParent(ctx, parent, result) +} + +type ChildSetResult[T client.Object] struct { + Children []ChildSetPartialResult[T] +} + +type ChildSetPartialResult[T client.Object] struct { + Id string + Child T + Err error +} + +func (r *ChildSetResult[T]) AggregateError() error { + var errs []error + for _, childResult := range r.Children { + errs = append(errs, childResult.Err) + } + return utilerrors.NewAggregate(errs) +} + +const childSetResultStashKey StashKey = "reconciler-runtime:childSetResult" + +func retrieveChildSetResult[T client.Object](ctx context.Context) ChildSetResult[T] { + value := RetrieveValue(ctx, childSetResultStashKey) + if result, ok := value.(ChildSetResult[T]); ok { + return result + } + return ChildSetResult[T]{} +} + +func stashChildSetResult[T client.Object](ctx context.Context, result ChildSetResult[T]) { + StashValue(ctx, childSetResultStashKey, result) +} + +func clearChildSetResult[T client.Object](ctx context.Context) ChildSetResult[T] { + value := ClearValue(ctx, childSetResultStashKey) + if result, ok := value.(ChildSetResult[T]); ok { + return result + } + return ChildSetResult[T]{} +} diff --git a/reconcilers/childset_test.go b/reconcilers/childset_test.go new file mode 100644 index 0000000..a3afcbc --- /dev/null +++ b/reconcilers/childset_test.go @@ -0,0 +1,607 @@ +/* +Copyright 2023 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/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestChildSetReconciler(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testFinalizer := "test.finalizer" + + idKey := fmt.Sprintf("%s/child-id", resources.GroupVersion.Group) + + 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"), + ) + }) + + configMapDesired := diecorev1.ConfigMapBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + AddData("foo", "bar") + configMapCreate := configMapDesired. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.ControlledBy(resource, scheme) + }) + configMapGiven := configMapCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.CreationTimestamp(now) + d.UID(types.UID("3b298fdb-b0b6-4603-9708-939e05daf183")) + }) + + configMapBlueDesired := configMapCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name(testName + "-blue") + d.AddAnnotation(idKey, "blue") + }) + configMapBlueCreate := configMapBlueDesired. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.ControlledBy(resource, scheme) + }) + configMapBlueGiven := configMapBlueCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.CreationTimestamp(now) + d.UID(types.UID("a0e91ff9-bf42-4bc7-9253-2a6581b07e4d")) + }) + + configMapGreenDesired := configMapCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Name(testName + "-green") + d.AddAnnotation(idKey, "green") + }) + configMapGreenCreate := configMapGreenDesired. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.ControlledBy(resource, scheme) + }) + configMapGreenGiven := configMapGreenCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.CreationTimestamp(now) + d.UID(types.UID("62af4b9a-767a-4f32-b62c-e4bccbfa8ef0")) + }) + + defaultChildSetReconciler := func(c reconcilers.Config) *reconcilers.ChildSetReconciler[*resources.TestResource, *corev1.ConfigMap, *corev1.ConfigMapList] { + return &reconcilers.ChildSetReconciler[*resources.TestResource, *corev1.ConfigMap, *corev1.ConfigMapList]{ + DesiredChildren: func(ctx context.Context, parent *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{}, nil + }, + IdentifyChild: func(child *corev1.ConfigMap) string { + annotations := child.GetAnnotations() + if annotations == nil { + return "" + } + return annotations[idKey] + }, + MergeBeforeUpdate: func(current, desired *corev1.ConfigMap) { + current.Data = desired.Data + }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *resources.TestResource, result reconcilers.ChildSetResult[*corev1.ConfigMap]) { + if err := result.AggregateError(); err != nil { + if apierrs.IsAlreadyExists(err) { + name := err.(apierrs.APIStatus).Status().Details.Name + parent.Status.MarkNotReady("NameConflict", "%q already exists", name) + } + return + } + + parent.Status.Fields = map[string]string{} + for _, childResult := range result.Children { + child := childResult.Child + id := childResult.Id + if child == nil { + continue + } + for k, v := range child.Data { + parent.Status.Fields[fmt.Sprintf("%s.%s", id, k)] = v + } + } + if len(parent.Status.Fields) == 0 { + parent.Status.Fields = nil + } + parent.Status.MarkReady() + }, + } + } + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "in sync no children": { + Resource: resourceReady.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildSetReconciler(c) + }, + }, + }, + "in sync with children": { + Resource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + configMapGreenGiven.DieReleasePtr(), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + configMapGreenDesired.DieReleasePtr(), + }, nil + } + return r + }, + }, + }, + "ignores resources that are not ours": { + Resource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + configMapGreenGiven.DieReleasePtr(), + // not our resource + diecorev1.ConfigMapBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + DieReleasePtr(), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + configMapGreenDesired.DieReleasePtr(), + }, nil + } + return r + }, + }, + }, + "create green child, preserving blue child": { + Resource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + configMapGreenDesired.DieReleasePtr(), + }, nil + } + return r + }, + }, + ExpectResource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName+"-green"), + }, + ExpectCreates: []client.Object{ + configMapGreenCreate.DieReleasePtr(), + }, + }, + "delete green child, preserving blue child": { + Resource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + configMapGreenGiven.DieReleasePtr(), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + }, nil + } + return r + }, + }, + ExpectResource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + }). + DieReleasePtr(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName+"-green"), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGreenGiven.DieReleasePtr(), scheme), + }, + }, + "update children": { + Resource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + configMapGreenGiven.DieReleasePtr(), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired. + AddData("foo", "updated-blue"). + DieReleasePtr(), + configMapGreenDesired. + AddData("foo", "updated-green"). + DieReleasePtr(), + }, nil + } + return r + }, + }, + ExpectResource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "updated-blue") + d.AddField("green.foo", "updated-green") + }). + DieReleasePtr(), + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", + `Updated ConfigMap %q`, testName+"-blue"), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", + `Updated ConfigMap %q`, testName+"-green"), + }, + ExpectUpdates: []client.Object{ + configMapBlueGiven. + AddData("foo", "updated-blue"). + DieReleasePtr(), + configMapGreenGiven. + AddData("foo", "updated-green"). + DieReleasePtr(), + }, + }, + "errors for desired children with empty id": { + Resource: resourceReady.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + // clear existing id annotation + d.DieStamp(func(r *metav1.ObjectMeta) { + delete(r.Annotations, idKey) + }) + }). + DieReleasePtr(), + }, nil + } + return r + }, + }, + ShouldErr: true, + }, + "errors for desired children with duplicate ids": { + Resource: resourceReady.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + configMapGreenDesired. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.AddAnnotation(idKey, "blue") + }). + DieReleasePtr(), + }, nil + } + return r + }, + }, + ShouldErr: true, + }, + "deletes actual children with duplicate ids": { + Resource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + configMapGreenGiven. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.AddAnnotation(idKey, "blue") + }). + DieReleasePtr(), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + }, nil + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName+"-blue"), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName+"-green"), + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created ConfigMap %q`, testName+"-blue"), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapBlueGiven.DieReleasePtr(), scheme), + rtesting.NewDeleteRefFromObject(configMapGreenGiven.DieReleasePtr(), scheme), + }, + ExpectCreates: []client.Object{ + configMapBlueCreate.DieReleasePtr(), + }, + }, + "deletes actual child resource missing id": { + Resource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + configMapGreenGiven.DieReleasePtr(), + configMapGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + configMapGreenDesired.DieReleasePtr(), + }, nil + } + return r + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted ConfigMap %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(configMapGiven.DieReleasePtr(), scheme), + }, + }, + "defines a finalizer when requested": { + Resource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + configMapGreenGiven.DieReleasePtr(), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.Finalizer = testFinalizer + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + configMapGreenDesired.DieReleasePtr(), + }, nil + } + return r + }, + }, + ExpectResource: resourceReady. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.ResourceVersion("1000") + d.Finalizers(testFinalizer) + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + ExpectEvents: []rtesting.Event{ + 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":["test.finalizer"],"resourceVersion":"999"}}`), + }, + }, + }, + "forwards error listing children": { + Resource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + configMapGreenGiven.DieReleasePtr(), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + configMapGreenDesired.DieReleasePtr(), + }, nil + } + return r + }, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("list", "ConfigMapList"), + }, + ShouldErr: true, + }, + "forwards error from child reconciler": { + Resource: resourceReady.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return []*corev1.ConfigMap{ + configMapBlueDesired.DieReleasePtr(), + configMapGreenDesired.DieReleasePtr(), + }, nil + } + return r + }, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("create", "ConfigMap"), + }, + ShouldErr: true, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeWarning, "CreationFailed", + `Failed to create ConfigMap %q: inducing failure for create ConfigMap`, testName+"-blue"), + }, + ExpectCreates: []client.Object{ + configMapBlueCreate.DieReleasePtr(), + }, + }, + "skip resource manager operations when OnlyReconcileChildStatus is returned": { + Resource: resource.DieReleasePtr(), + GivenObjects: []client.Object{ + configMapBlueGiven.DieReleasePtr(), + configMapGreenGiven.DieReleasePtr(), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return nil, reconcilers.OnlyReconcileChildStatus + } + return r + }, + }, + ExpectResource: resourceReady. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + }, + "skip resource manager operations when OnlyReconcileChildStatus is returned, even when there are no actual or desired children": { + Resource: resource. + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("blue.foo", "bar") + d.AddField("green.foo", "bar") + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return nil, reconcilers.OnlyReconcileChildStatus + } + return r + }, + }, + ExpectResource: resourceReady.DieReleasePtr(), + }, + "errors when desired children returns an error": { + Resource: resourceReady.DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + r := defaultChildSetReconciler(c) + r.DesiredChildren = func(ctx context.Context, resource *resources.TestResource) ([]*corev1.ConfigMap, error) { + return nil, fmt.Errorf("test") + } + 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) + }) +} diff --git a/reconcilers/reconcilers.go b/reconcilers/reconcilers.go index a5b64cc..8fd8563 100644 --- a/reconcilers/reconcilers.go +++ b/reconcilers/reconcilers.go @@ -1079,14 +1079,16 @@ func (r *ChildReconciler[T, CT, CLT]) init() { if r.Name == "" { r.Name = fmt.Sprintf("%sChildReconciler", typeName(r.ChildType)) } - 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, + 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, + } } }) } @@ -1141,6 +1143,13 @@ func (r *ChildReconciler[T, CT, CLT]) validate(ctx context.Context) error { 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() @@ -1241,13 +1250,10 @@ func (r *ChildReconciler[T, CT, CLT]) desiredChild(ctx context.Context, resource } func (r *ChildReconciler[T, CT, CLT]) filterChildren(resource T, children CLT) []CT { - childrenValue := reflect.ValueOf(children).Elem() - itemsValue := childrenValue.FieldByName("Items") items := []CT{} - for i := 0; i < itemsValue.Len(); i++ { - obj := itemsValue.Index(i).Addr().Interface().(CT) - if r.ourChild(resource, obj) { - items = append(items, obj) + for _, child := range extractItems[CT](children) { + if r.ourChild(resource, child) { + items = append(items, child) } } return items @@ -1930,3 +1936,15 @@ 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_validate_test.go b/reconcilers/reconcilers_validate_test.go index 5b8a1e0..254e5d0 100644 --- a/reconcilers/reconcilers_validate_test.go +++ b/reconcilers/reconcilers_validate_test.go @@ -500,6 +500,168 @@ func TestChildReconciler_validate(t *testing.T) { } } +func TestChildSetReconciler_validate(t *testing.T) { + tests := []struct { + name string + parent *corev1.ConfigMap + reconciler *ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList] + shouldErr string + }{ + { + name: "empty", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{}, + shouldErr: `ChildSetReconciler "" must implement DesiredChildren`, + }, + { + name: "valid", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + ChildType: &corev1.Pod{}, + ChildListType: &corev1.PodList{}, + DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *corev1.ConfigMap, result ChildSetResult[*corev1.Pod]) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + }, + { + name: "ChildType missing", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + Name: "ChildType missing", + // ChildType: &corev1.Pod{}, + ChildListType: &corev1.PodList{}, + DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *corev1.ConfigMap, result ChildSetResult[*corev1.Pod]) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + }, + { + name: "ChildListType missing", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + Name: "ChildListType missing", + ChildType: &corev1.Pod{}, + // ChildListType: &corev1.PodList{}, + DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *corev1.ConfigMap, result ChildSetResult[*corev1.Pod]) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + }, + { + name: "DesiredChildren missing", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + Name: "DesiredChildren missing", + ChildType: &corev1.Pod{}, + ChildListType: &corev1.PodList{}, + // DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *corev1.ConfigMap, result ChildSetResult[*corev1.Pod]) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + shouldErr: `ChildSetReconciler "DesiredChildren missing" must implement DesiredChildren`, + }, + { + name: "ReflectChildrenStatusOnParent missing", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + Name: "ReflectChildrenStatusOnParent missing", + ChildType: &corev1.Pod{}, + ChildListType: &corev1.PodList{}, + DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + // ReflectChildrenStatusOnParent: func(parent *corev1.ConfigMap, child *corev1.Pod, err error) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + shouldErr: `ChildSetReconciler "ReflectChildrenStatusOnParent missing" must implement ReflectChildrenStatusOnParent`, + }, + { + name: "IdentifyChild missing", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + Name: "IdentifyChild missing", + ChildType: &corev1.Pod{}, + ChildListType: &corev1.PodList{}, + DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *corev1.ConfigMap, result ChildSetResult[*corev1.Pod]) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + // IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + shouldErr: `ChildSetReconciler "IdentifyChild missing" must implement IdentifyChild`, + }, + { + name: "ListOptions", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + ChildType: &corev1.Pod{}, + ChildListType: &corev1.PodList{}, + DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *corev1.ConfigMap, result ChildSetResult[*corev1.Pod]) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + ListOptions: func(ctx context.Context, parent *corev1.ConfigMap) []client.ListOption { return []client.ListOption{} }, + IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + }, + { + name: "Finalizer without OurChild", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + Name: "Finalizer without OurChild", + ChildType: &corev1.Pod{}, + ChildListType: &corev1.PodList{}, + DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *corev1.ConfigMap, result ChildSetResult[*corev1.Pod]) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + Finalizer: "my-finalizer", + IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + shouldErr: `ChildSetReconciler "Finalizer without OurChild" must implement OurChild since owner references are not used`, + }, + { + name: "SkipOwnerReference without OurChild", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + Name: "SkipOwnerReference without OurChild", + ChildType: &corev1.Pod{}, + ChildListType: &corev1.PodList{}, + DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *corev1.ConfigMap, result ChildSetResult[*corev1.Pod]) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + SkipOwnerReference: true, + IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + shouldErr: `ChildSetReconciler "SkipOwnerReference without OurChild" must implement OurChild since owner references are not used`, + }, + { + name: "OurChild", + parent: &corev1.ConfigMap{}, + reconciler: &ChildSetReconciler[*corev1.ConfigMap, *corev1.Pod, *corev1.PodList]{ + ChildType: &corev1.Pod{}, + ChildListType: &corev1.PodList{}, + DesiredChildren: func(ctx context.Context, parent *corev1.ConfigMap) ([]*corev1.Pod, error) { return nil, nil }, + ReflectChildrenStatusOnParent: func(ctx context.Context, parent *corev1.ConfigMap, result ChildSetResult[*corev1.Pod]) {}, + MergeBeforeUpdate: func(current, desired *corev1.Pod) {}, + OurChild: func(parent *corev1.ConfigMap, child *corev1.Pod) bool { return false }, + IdentifyChild: func(child *corev1.Pod) string { return "" }, + }, + }, + } + + for _, c := range tests { + t.Run(c.name, func(t *testing.T) { + ctx := StashResourceType(context.TODO(), c.parent) + err := c.reconciler.validate(ctx) + if (err != nil) != (c.shouldErr != "") || (c.shouldErr != "" && c.shouldErr != err.Error()) { + t.Errorf("validate() error = %q, shouldErr %q", err.Error(), c.shouldErr) + } + }) + } +} + func TestCastResource_validate(t *testing.T) { tests := []struct { name string diff --git a/reconcilers/stash.go b/reconcilers/stash.go index 4227bf5..8c2886d 100644 --- a/reconcilers/stash.go +++ b/reconcilers/stash.go @@ -20,18 +20,27 @@ func WithStash(ctx context.Context) context.Context { type StashKey string -func StashValue(ctx context.Context, key StashKey, value interface{}) { +func retrieveStashMap(ctx context.Context) stashMap { stash, ok := ctx.Value(stashNonce).(stashMap) if !ok { panic(fmt.Errorf("context not configured for stashing, call `ctx = WithStash(ctx)`")) } + return stash +} + +func StashValue(ctx context.Context, key StashKey, value interface{}) { + stash := retrieveStashMap(ctx) stash[key] = value } func RetrieveValue(ctx context.Context, key StashKey) interface{} { - stash, ok := ctx.Value(stashNonce).(stashMap) - if !ok { - panic(fmt.Errorf("context not configured for stashing, call `ctx = WithStash(ctx)`")) - } + stash := retrieveStashMap(ctx) return stash[key] } + +func ClearValue(ctx context.Context, key StashKey) interface{} { + stash := retrieveStashMap(ctx) + value := stash[key] + delete(stash, key) + return value +}