From 376b743ce69f49cbf6092964d436dde02fb71417 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Thu, 10 Aug 2023 15:55:26 -0400 Subject: [PATCH] Duck type clients and reconcilers (#402) The clients used by reconciler runtime are now able to interact with duck typed resources, with some limitations. The support extends to working inside reconcilers, including with the ResourceReconciler. Duck typed resources are structured types that implement the client.Object interface but are not registered in the scheme for a GVK. The APIVersion and Kind fields must be set on the object. Using ResourceReconciler with a duck type is similar to using an unstructured type and then casting to the duck type, however, starting with a duck type will allow the resource to participate in common structured operations like setting the status observed generation, initializing conditions, and coalescing condition timestamps when the condition did not otherwise change. Known limitations include: - Create client methods are not supported. Resources always need to be created with a concrete type. - Update client methods are not supported. Use Patch methods instead. - ResourceReconciler will patch status if a mutation is detected instead of update. The bytes of the patch must be defined on the ReconcilerTestCase for tests that result in a patch request. Ephemeral values, like LastTransitionTime timestamps, need to be pinned to known values. Use ReconcilerTestCase#Now for timestamp values. To help solve the ephemeral nature of time, this change also introduces a mechanism to retrieve the current time off the context via rtime.RetrieveNow. Each root reconciler stashes the current time. Additionally, each test case can define the Now field with a custom timestamp that is stashed as the current time. The condition manager Manage method is deprecated in favor of ManageWithContext, which will use the stashed time when constructing conditions. The status InitializeConditions method should be updated to accept a context when it is defined, the no arg variant is deprecated. When using either deprecated method without a context, time.Now is used instead of the stashed time. Signed-off-by: Scott Andrews --- README.md | 28 +- apis/conditionset.go | 26 +- duck/client.go | 314 +++++++++++ internal/resources/dies/dies.go | 23 + internal/resources/dies/zz_generated.die.go | 402 +++++++++++++++ .../resources/dies/zz_generated.die_test.go | 18 + internal/resources/resource.go | 37 +- internal/resources/resource_duck.go | 74 +++ internal/resources/zz_generated.deepcopy.go | 81 +++ reconcilers/aggregate.go | 20 +- reconcilers/child_test.go | 6 +- reconcilers/childset.go | 4 + reconcilers/childset_test.go | 4 +- reconcilers/config.go | 5 +- reconcilers/resource.go | 71 ++- reconcilers/resource_test.go | 486 ++++++++++++++++++ reconcilers/validate_test.go | 2 +- reconcilers/webhook.go | 3 + testing/config.go | 3 +- testing/reconciler.go | 9 + testing/subreconciler.go | 9 + testing/webhook.go | 9 + time/now.go | 32 ++ 23 files changed, 1606 insertions(+), 60 deletions(-) create mode 100644 duck/client.go create mode 100644 internal/resources/resource_duck.go create mode 100644 time/now.go diff --git a/README.md b/README.md index fac2dcb..a635f61 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,28 @@ Within an existing Kubebuilder or controller-runtime project, reconciler-runtime - [Status](#status) - [Finalizers](#finalizers) - [ResourceManager](#resourcemanager) + - [Time](#time) - [Breaking Changes](#breaking-changes) + - [Current Deprecations](#current-deprecations) - [Contributing](#contributing) - [Acknowledgements](#acknowledgements) - [License](#license) ## Reconcilers +Reconcilers can operate on three different types of objects: +- structured types (e.g. [`corev1.Pod`](https://pkg.go.dev/k8s.io/api/core/v1#Pod)) +- unstructured types (e.g. [`unstructured.Unstructured`](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured#Unstructured)) +- semi-structured duck types (e.g. [`PodSpecable`](https://pkg.go.dev/knative.dev/pkg/apis/duck/v1#PodSpecable), [`ProvisionedService`](https://servicebinding.io/spec/core/1.0.0/#provisioned-service)) + +Structured types are often the best choice as they allow easy interaction with the full object and have full client support. The type must be registered with the [`Scheme`](https://pkg.go.dev/k8s.io/apimachinery/pkg/runtime#Scheme). The type must be pre-defined and compiled into the controller. + +Unstructured types are useful when the resources are not known at compile time and full access to the resource and client methods is desired. Since the type is not known in advance, it cannot be registered with the scheme. Interacting with the object is difficult as traversing the object requires lots of casts or reflection. The [`TypeMeta`](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#TypeMeta) `APIVersion` and `Kind` fields must be defined for the client to operate on the object. + +Semi-structured duck types offer a middle ground. They are strongly typed, but only cover a subset of the full object. They are intended to facilitate normalized operations across a number of concrete types that share a common subset of their own schema. The concrete objects compatible with this type are not required to be known at compile time. Because duck types are not full objects, client operations for `Create` and `Update` are disallowed (`Patch` is available). Like unstructured objects, the duck type should not be registered in the scheme, and the [`TypeMeta`](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1#TypeMeta) `APIVersion` and `Kind` fields must be defined for the client to operate on the object. + +The controller-runtime client is able to work with structured and unstructured objects natively, reconciler-runtime adds support for duck typed objects via the [`duck.NewDuckAwareClientWrapper`](https://pkg.go.dev/github.com/vmware-labs/reconciler-runtime/duck#NewDuckAwareClientWrapper). + ### ResourceReconciler @@ -566,7 +581,7 @@ The table test pattern is used to declare each test case in a test suite with th The tests make extensive use of given and mutated resources. It is recommended to use a library like [dies](https://dies.dev) to reduce boilerplate code and to highlight the delta unique to each test. -There are two test suites, one for reconcilers and an optimized harness for testing sub reconcilers. +There are three test suites: for [testing reconcilers](#reconcilertests), an optimized harness for [testing sub reconcilers](#subreconcilertests), and for [testing admission webhooks](#admissionwebhooktests). @@ -942,6 +957,12 @@ If configured, a [finalizer](#finalizers) can be managed on the resource which w If requested, the managed resource will be tracked for the resource. +### Time + +Reconcilers that capture timestamps can be natoriously difficult to test, as the output will be different for every execution. While we don't have a time machine, reconciler-runtime provides an alterate API to fetch the current time within a reconciler. [`rtime.RetrieveTime(context.Context)`](https://pkg.go.dev/github.com/vmware-labs/reconciler-runtime/time#RetrieveTime) can be used within a reconciler to get the [`time.Time`](https://pkg.go.dev/time#Time) when the reconciler request started processing. The value returned is guarenteed to remain stable for the lifespan of the reconcile request. Calls to [`time.Now`](https://pkg.go.dev/time#Now) will continue to return an up to date timestamp. + +Reconciler tests can seed this timestamp by defining the [`Now`](https://pkg.go.dev/github.com/vmware-labs/reconciler-runtime/testing#ReconcilerTestCase.Now) field on the test case. The reconciler will be run with the desired time instead of "now". The timestamp set on the test case can also be used in the expectations to pin values that would otherwise float. + ## Breaking Changes Known breaking changes are captured in the [release notes](https://github.com/vmware-labs/reconciler-runtime/releases), it is strongly recomened to review the release notes before upgrading to a new version of reconciler-runtime. When possible, breaking changes are first marked as deprecations before full removal in a later release. Patch releases will be issued to fix significant bugs and unintentional breaking changes. @@ -950,6 +971,11 @@ We strive to release reconciler-runtime against the latest Kuberentes and contro reconciler-runtime is rapidly evolving. While we strive for API compatability between releases, functionality that is better handled using a different API may be removed. Release version numbers follow semver. +### Current Deprecations + +- status `InitiazeConditions()` is deprecated in favor of `InitializeConditions(context.Context)`. Support may be removed in a future release, users are encuraged to migrate. +- `ConditionSet#Manage` is deprecated in favor of `ConditionSet#ManageWithContext`. Support may be removed in a future release, users are encuraged to migrate. + ## Contributing The reconciler-runtime project team welcomes contributions from the community. If you wish to contribute code and you have not signed our contributor license agreement (CLA), our bot will update the issue when you open a Pull Request. For any questions about the CLA process, please refer to our [FAQ](https://cla.vmware.com/faq). For more detailed information, refer to [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/apis/conditionset.go b/apis/conditionset.go index b0a9691..193307c 100644 --- a/apis/conditionset.go +++ b/apis/conditionset.go @@ -21,12 +21,13 @@ SPDX-License-Identifier: Apache-2.0 package apis import ( + "context" + "fmt" "reflect" "sort" "time" - "fmt" - + rtime "github.com/vmware-labs/reconciler-runtime/time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -46,8 +47,6 @@ type ConditionsAccessor interface { SetConditions([]metav1.Condition) } -type NowFunc func() time.Time - // ConditionSet is an abstract collection of the possible ConditionType values // that a particular resource might expose. It also holds the "happy condition" // for that resource, which we define to be one of Ready or Succeeded depending @@ -156,14 +155,23 @@ var _ ConditionManager = (*conditionsImpl)(nil) type conditionsImpl struct { ConditionSet accessor ConditionsAccessor + now time.Time } +// Deprecated: use ManageWithContext // Manage creates a ConditionManager from an accessor object using the original // ConditionSet as a reference. Status must be a pointer to a struct. func (r ConditionSet) Manage(status ConditionsAccessor) ConditionManager { + return r.ManageWithContext(context.TODO(), status) +} + +// Manage creates a ConditionManager from an accessor object using the original +// ConditionSet as a reference. Status must be a pointer to a struct. +func (r ConditionSet) ManageWithContext(ctx context.Context, status ConditionsAccessor) ConditionManager { return conditionsImpl{ accessor: status, ConditionSet: r, + now: rtime.RetrieveNow(ctx), } } @@ -210,7 +218,7 @@ func (r conditionsImpl) SetCondition(new metav1.Condition) { } } } - new.LastTransitionTime = metav1.NewTime(time.Now()).Rfc3339Copy() + new.LastTransitionTime = metav1.NewTime(r.now).Rfc3339Copy() conditions = append(conditions, new) // Sorted for convenience of the consumer, i.e. kubectl. sort.Slice(conditions, func(i, j int) bool { return conditions[i].Type < conditions[j].Type }) @@ -266,6 +274,10 @@ func (r conditionsImpl) MarkTrue(t string, reason, messageFormat string, message Message: fmt.Sprintf(messageFormat, messageA...), }) + if len(r.dependents) == 0 { + return + } + // check the dependents. for _, cond := range r.dependents { c := r.GetCondition(cond) @@ -294,6 +306,10 @@ func (r conditionsImpl) MarkUnknown(t string, reason, messageFormat string, mess Message: fmt.Sprintf(messageFormat, messageA...), }) + if len(r.dependents) == 0 { + return + } + // check the dependents. isDependent := false for _, cond := range r.dependents { diff --git a/duck/client.go b/duck/client.go new file mode 100644 index 0000000..0cb4466 --- /dev/null +++ b/duck/client.go @@ -0,0 +1,314 @@ +/* +Copyright 2023 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package duck + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// IsDuck returns true for types are not registered in the scheme and have a GVK set on the object +func IsDuck(obj runtime.Object, scheme *runtime.Scheme) bool { + if _, _, err := scheme.ObjectKinds(obj); runtime.IsNotRegisteredError(err) { + return obj.GetObjectKind() != nil && !obj.GetObjectKind().GroupVersionKind().Empty() + } + + return false +} + +type SchemeAccessor interface { + Scheme() *runtime.Scheme +} + +func NewDuckAwareAPIReaderWrapper(reader client.Reader, scheme SchemeAccessor) client.Reader { + return &duckAwareAPIReaderWrapper{ + reader: reader, + scheme: scheme, + } +} + +type duckAwareAPIReaderWrapper struct { + reader client.Reader + scheme SchemeAccessor +} + +func (c *duckAwareAPIReaderWrapper) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if !IsDuck(obj, c.scheme.Scheme()) { + return c.reader.Get(ctx, key, obj, opts...) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: uObj} + if err := c.reader.Get(ctx, key, u, opts...); err != nil { + return err + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj) +} + +func (c *duckAwareAPIReaderWrapper) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + if !IsDuck(list, c.scheme.Scheme()) { + return c.reader.List(ctx, list, opts...) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(list) + if err != nil { + return err + } + u := &unstructured.UnstructuredList{Object: uObj} + if err := c.reader.List(ctx, u, opts...); err != nil { + return err + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, list) +} + +func NewDuckAwareClientWrapper(client client.Client) client.Client { + return &duckAwareClientWrapper{ + Reader: NewDuckAwareAPIReaderWrapper(client, client), + client: client, + } +} + +type duckAwareClientWrapper struct { + client.Reader + client client.Client +} + +func (c *duckAwareClientWrapper) Watch(ctx context.Context, list client.ObjectList, opts ...client.ListOption) (watch.Interface, error) { + ww, ok := c.client.(client.WithWatch) + if !ok { + panic(fmt.Errorf("unable to call Watch with wrapped client that does not implement client.WithWatch")) + } + + if !IsDuck(list, c.Scheme()) { + return ww.Watch(ctx, list, opts...) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(list) + if err != nil { + return nil, err + } + u := &unstructured.UnstructuredList{Object: uObj} + w, err := ww.Watch(ctx, u, opts...) + if err != nil { + return nil, err + } + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, list); err != nil { + return nil, err + } + return w, nil +} + +func (c *duckAwareClientWrapper) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + if !IsDuck(obj, c.Scheme()) { + return c.client.Create(ctx, obj, opts...) + } + + return fmt.Errorf("Create is not supported for the duck typed objects") +} + +func (c *duckAwareClientWrapper) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + if !IsDuck(obj, c.Scheme()) { + return c.client.Update(ctx, obj, opts...) + } + + return fmt.Errorf("Update is not supported for the duck typed objects, use Patch instead") +} + +func (c *duckAwareClientWrapper) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if !IsDuck(obj, c.Scheme()) { + return c.client.Patch(ctx, obj, patch, opts...) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: uObj} + if err := c.client.Patch(ctx, u, patch, opts...); err != nil { + return err + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj) +} + +func (c *duckAwareClientWrapper) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + if !IsDuck(obj, c.Scheme()) { + return c.client.Delete(ctx, obj, opts...) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: uObj} + if err := c.client.Delete(ctx, u, opts...); err != nil { + return err + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj) +} + +func (c *duckAwareClientWrapper) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + if !IsDuck(obj, c.Scheme()) { + return c.client.DeleteAllOf(ctx, obj, opts...) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: uObj} + if err := c.client.DeleteAllOf(ctx, u, opts...); err != nil { + return err + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj) +} + +func (c *duckAwareClientWrapper) Status() client.SubResourceWriter { + return &duckAwareSubResourceWriterWrapper{ + subResourceWriter: c.client.Status(), + scheme: c.client, + } +} + +func (c *duckAwareClientWrapper) SubResource(subResource string) client.SubResourceClient { + return &duckAwareSubResourceClientWrapper{ + subResourceClient: c.client.SubResource(subResource), + scheme: c.client, + } +} + +func (c *duckAwareClientWrapper) Scheme() *runtime.Scheme { + return c.client.Scheme() +} + +func (c *duckAwareClientWrapper) RESTMapper() meta.RESTMapper { + return c.client.RESTMapper() +} + +func (c *duckAwareClientWrapper) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + if !IsDuck(obj, c.Scheme()) { + return c.client.GroupVersionKindFor(obj) + } + + // TODO call upstream directly once kubernetes-sigs/controller-runtime#2434 lands + objKind := obj.GetObjectKind() + if objKind == nil || objKind.GroupVersionKind().Empty() { + return schema.GroupVersionKind{}, fmt.Errorf("object must directly define APIVersion and Kind") + } + return objKind.GroupVersionKind(), nil +} + +func (c *duckAwareClientWrapper) IsObjectNamespaced(obj runtime.Object) (bool, error) { + if !IsDuck(obj, c.Scheme()) { + return c.client.IsObjectNamespaced(obj) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return false, err + } + u := &unstructured.Unstructured{Object: uObj} + return c.client.IsObjectNamespaced(u) +} + +type duckAwareSubResourceWriterWrapper struct { + subResourceWriter client.SubResourceWriter + scheme SchemeAccessor +} + +func (w *duckAwareSubResourceWriterWrapper) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + if !IsDuck(obj, w.scheme.Scheme()) { + return w.subResourceWriter.Create(ctx, obj, subResource, opts...) + } + + return fmt.Errorf("Create is not supported for the duck typed objects") +} + +func (w *duckAwareSubResourceWriterWrapper) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + if !IsDuck(obj, w.scheme.Scheme()) { + return w.subResourceWriter.Update(ctx, obj, opts...) + } + + return fmt.Errorf("Update is not supported for the duck typed objects, use Patch instead") +} + +func (w *duckAwareSubResourceWriterWrapper) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if !IsDuck(obj, w.scheme.Scheme()) { + return w.subResourceWriter.Patch(ctx, obj, patch, opts...) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: uObj} + if err := w.subResourceWriter.Patch(ctx, u, patch, opts...); err != nil { + return err + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj) +} + +type duckAwareSubResourceClientWrapper struct { + subResourceClient client.SubResourceClient + scheme SchemeAccessor +} + +func (c *duckAwareSubResourceClientWrapper) Get(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceGetOption) error { + if !IsDuck(obj, c.scheme.Scheme()) { + return c.subResourceClient.Get(ctx, obj, subResource, opts...) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: uObj} + if err := c.subResourceClient.Get(ctx, u, subResource, opts...); err != nil { + return err + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj) +} + +func (c *duckAwareSubResourceClientWrapper) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + if !IsDuck(obj, c.scheme.Scheme()) { + return c.subResourceClient.Create(ctx, obj, subResource, opts...) + } + + return fmt.Errorf("Create is not supported for the duck typed objects") +} + +func (c *duckAwareSubResourceClientWrapper) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + if !IsDuck(obj, c.scheme.Scheme()) { + return c.subResourceClient.Update(ctx, obj, opts...) + } + + return fmt.Errorf("Update is not supported for the duck typed objects, use Patch instead") +} + +func (c *duckAwareSubResourceClientWrapper) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + if !IsDuck(obj, c.scheme.Scheme()) { + return c.subResourceClient.Patch(ctx, obj, patch, opts...) + } + + uObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return err + } + u := &unstructured.Unstructured{Object: uObj} + if err := c.subResourceClient.Patch(ctx, u, patch, opts...); err != nil { + return err + } + return runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, obj) +} diff --git a/internal/resources/dies/dies.go b/internal/resources/dies/dies.go index 826888e..dea6d16 100644 --- a/internal/resources/dies/dies.go +++ b/internal/resources/dies/dies.go @@ -76,3 +76,26 @@ func (d *TestResourceNilableStatusDie) StatusDie(fn func(d *TestResourceStatusDi r.Status = d.DieReleasePtr() }) } + +// +die:object=true +type _ = resources.TestDuck + +func (d *TestDuckDie) StatusDie(fn func(d *TestResourceStatusDie)) *TestDuckDie { + return d.DieStamp(func(r *resources.TestDuck) { + d := TestResourceStatusBlank.DieImmutable(false).DieFeed(r.Status) + fn(d) + r.Status = d.DieRelease() + }) +} + +// +die +type _ = resources.TestDuckSpec + +func (d *TestDuckSpecDie) AddField(key, value string) *TestDuckSpecDie { + return d.DieStamp(func(r *resources.TestDuckSpec) { + if r.Fields == nil { + r.Fields = map[string]string{} + } + r.Fields[key] = value + }) +} diff --git a/internal/resources/dies/zz_generated.die.go b/internal/resources/dies/zz_generated.die.go index e73f65a..971a22a 100644 --- a/internal/resources/dies/zz_generated.die.go +++ b/internal/resources/dies/zz_generated.die.go @@ -1503,3 +1503,405 @@ func (d *TestResourceNilableStatusDie) Status(v *resources.TestResourceStatus) * r.Status = v }) } + +var TestDuckBlank = (&TestDuckDie{}).DieFeed(resources.TestDuck{}) + +type TestDuckDie struct { + v1.FrozenObjectMeta + mutable bool + r resources.TestDuck +} + +// DieImmutable returns a new die for the current die's state that is either mutable (`false`) or immutable (`true`). +func (d *TestDuckDie) DieImmutable(immutable bool) *TestDuckDie { + if d.mutable == !immutable { + return d + } + d = d.DeepCopy() + d.mutable = !immutable + return d +} + +// DieFeed returns a new die with the provided resource. +func (d *TestDuckDie) DieFeed(r resources.TestDuck) *TestDuckDie { + if d.mutable { + d.FrozenObjectMeta = v1.FreezeObjectMeta(r.ObjectMeta) + d.r = r + return d + } + return &TestDuckDie{ + FrozenObjectMeta: v1.FreezeObjectMeta(r.ObjectMeta), + mutable: d.mutable, + r: r, + } +} + +// DieFeedPtr returns a new die with the provided resource pointer. If the resource is nil, the empty value is used instead. +func (d *TestDuckDie) DieFeedPtr(r *resources.TestDuck) *TestDuckDie { + if r == nil { + r = &resources.TestDuck{} + } + return d.DieFeed(*r) +} + +// DieFeedJSON returns a new die with the provided JSON. Panics on error. +func (d *TestDuckDie) DieFeedJSON(j []byte) *TestDuckDie { + r := resources.TestDuck{} + if err := json.Unmarshal(j, &r); err != nil { + panic(err) + } + return d.DieFeed(r) +} + +// DieFeedYAML returns a new die with the provided YAML. Panics on error. +func (d *TestDuckDie) DieFeedYAML(y []byte) *TestDuckDie { + r := resources.TestDuck{} + if err := yaml.Unmarshal(y, &r); err != nil { + panic(err) + } + return d.DieFeed(r) +} + +// DieFeedYAMLFile returns a new die loading YAML from a file path. Panics on error. +func (d *TestDuckDie) DieFeedYAMLFile(name string) *TestDuckDie { + y, err := osx.ReadFile(name) + if err != nil { + panic(err) + } + return d.DieFeedYAML(y) +} + +// DieFeedRawExtension returns the resource managed by the die as an raw extension. Panics on error. +func (d *TestDuckDie) DieFeedRawExtension(raw runtime.RawExtension) *TestDuckDie { + j, err := json.Marshal(raw) + if err != nil { + panic(err) + } + return d.DieFeedJSON(j) +} + +// DieRelease returns the resource managed by the die. +func (d *TestDuckDie) DieRelease() resources.TestDuck { + if d.mutable { + return d.r + } + return *d.r.DeepCopy() +} + +// DieReleasePtr returns a pointer to the resource managed by the die. +func (d *TestDuckDie) DieReleasePtr() *resources.TestDuck { + r := d.DieRelease() + return &r +} + +// DieReleaseUnstructured returns the resource managed by the die as an unstructured object. Panics on error. +func (d *TestDuckDie) DieReleaseUnstructured() *unstructured.Unstructured { + r := d.DieReleasePtr() + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(r) + if err != nil { + panic(err) + } + return &unstructured.Unstructured{ + Object: u, + } +} + +// DieReleaseJSON returns the resource managed by the die as JSON. Panics on error. +func (d *TestDuckDie) DieReleaseJSON() []byte { + r := d.DieReleasePtr() + j, err := json.Marshal(r) + if err != nil { + panic(err) + } + return j +} + +// DieReleaseYAML returns the resource managed by the die as YAML. Panics on error. +func (d *TestDuckDie) DieReleaseYAML() []byte { + r := d.DieReleasePtr() + y, err := yaml.Marshal(r) + if err != nil { + panic(err) + } + return y +} + +// DieReleaseRawExtension returns the resource managed by the die as an raw extension. Panics on error. +func (d *TestDuckDie) DieReleaseRawExtension() runtime.RawExtension { + j := d.DieReleaseJSON() + raw := runtime.RawExtension{} + if err := json.Unmarshal(j, &raw); err != nil { + panic(err) + } + return raw +} + +// DieStamp returns a new die with the resource passed to the callback function. The resource is mutable. +func (d *TestDuckDie) DieStamp(fn func(r *resources.TestDuck)) *TestDuckDie { + r := d.DieRelease() + fn(&r) + return d.DieFeed(r) +} + +// Experimental: DieStampAt uses a JSON path (http://goessner.net/articles/JsonPath/) expression to stamp portions of the resource. The callback is invoked with each JSON path match. Panics if the callback function does not accept a single argument of the same type as found on the resource at the target location. +// +// Future iterations will improve type coercion from the resource to the callback argument. +func (d *TestDuckDie) DieStampAt(jp string, fn interface{}) *TestDuckDie { + return d.DieStamp(func(r *resources.TestDuck) { + cp := jsonpath.New("") + if err := cp.Parse(fmtx.Sprintf("{%s}", jp)); err != nil { + panic(err) + } + cr, err := cp.FindResults(r) + if err != nil { + // errors are expected if a path is not found + return + } + for _, cv := range cr[0] { + args := []reflectx.Value{cv} + reflectx.ValueOf(fn).Call(args) + } + }) +} + +// DeepCopy returns a new die with equivalent state. Useful for snapshotting a mutable die. +func (d *TestDuckDie) DeepCopy() *TestDuckDie { + r := *d.r.DeepCopy() + return &TestDuckDie{ + FrozenObjectMeta: v1.FreezeObjectMeta(r.ObjectMeta), + mutable: d.mutable, + r: r, + } +} + +var _ runtime.Object = (*TestDuckDie)(nil) + +func (d *TestDuckDie) DeepCopyObject() runtime.Object { + return d.r.DeepCopy() +} + +func (d *TestDuckDie) GetObjectKind() schema.ObjectKind { + r := d.DieRelease() + return r.GetObjectKind() +} + +func (d *TestDuckDie) MarshalJSON() ([]byte, error) { + return json.Marshal(d.r) +} + +func (d *TestDuckDie) UnmarshalJSON(b []byte) error { + if d == TestDuckBlank { + return fmtx.Errorf("cannot unmarshal into the blank die, create a copy first") + } + if !d.mutable { + return fmtx.Errorf("cannot unmarshal into immutable dies, create a mutable version first") + } + r := &resources.TestDuck{} + err := json.Unmarshal(b, r) + *d = *d.DieFeed(*r) + return err +} + +// APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources +func (d *TestDuckDie) APIVersion(v string) *TestDuckDie { + return d.DieStamp(func(r *resources.TestDuck) { + r.APIVersion = v + }) +} + +// Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds +func (d *TestDuckDie) Kind(v string) *TestDuckDie { + return d.DieStamp(func(r *resources.TestDuck) { + r.Kind = v + }) +} + +// MetadataDie stamps the resource's ObjectMeta field with a mutable die. +func (d *TestDuckDie) MetadataDie(fn func(d *v1.ObjectMetaDie)) *TestDuckDie { + return d.DieStamp(func(r *resources.TestDuck) { + d := v1.ObjectMetaBlank.DieImmutable(false).DieFeed(r.ObjectMeta) + fn(d) + r.ObjectMeta = d.DieRelease() + }) +} + +// SpecDie stamps the resource's spec field with a mutable die. +func (d *TestDuckDie) SpecDie(fn func(d *TestDuckSpecDie)) *TestDuckDie { + return d.DieStamp(func(r *resources.TestDuck) { + d := TestDuckSpecBlank.DieImmutable(false).DieFeed(r.Spec) + fn(d) + r.Spec = d.DieRelease() + }) +} + +func (d *TestDuckDie) Spec(v resources.TestDuckSpec) *TestDuckDie { + return d.DieStamp(func(r *resources.TestDuck) { + r.Spec = v + }) +} + +func (d *TestDuckDie) Status(v resources.TestResourceStatus) *TestDuckDie { + return d.DieStamp(func(r *resources.TestDuck) { + r.Status = v + }) +} + +var TestDuckSpecBlank = (&TestDuckSpecDie{}).DieFeed(resources.TestDuckSpec{}) + +type TestDuckSpecDie struct { + mutable bool + r resources.TestDuckSpec +} + +// DieImmutable returns a new die for the current die's state that is either mutable (`false`) or immutable (`true`). +func (d *TestDuckSpecDie) DieImmutable(immutable bool) *TestDuckSpecDie { + if d.mutable == !immutable { + return d + } + d = d.DeepCopy() + d.mutable = !immutable + return d +} + +// DieFeed returns a new die with the provided resource. +func (d *TestDuckSpecDie) DieFeed(r resources.TestDuckSpec) *TestDuckSpecDie { + if d.mutable { + d.r = r + return d + } + return &TestDuckSpecDie{ + mutable: d.mutable, + r: r, + } +} + +// DieFeedPtr returns a new die with the provided resource pointer. If the resource is nil, the empty value is used instead. +func (d *TestDuckSpecDie) DieFeedPtr(r *resources.TestDuckSpec) *TestDuckSpecDie { + if r == nil { + r = &resources.TestDuckSpec{} + } + return d.DieFeed(*r) +} + +// DieFeedJSON returns a new die with the provided JSON. Panics on error. +func (d *TestDuckSpecDie) DieFeedJSON(j []byte) *TestDuckSpecDie { + r := resources.TestDuckSpec{} + if err := json.Unmarshal(j, &r); err != nil { + panic(err) + } + return d.DieFeed(r) +} + +// DieFeedYAML returns a new die with the provided YAML. Panics on error. +func (d *TestDuckSpecDie) DieFeedYAML(y []byte) *TestDuckSpecDie { + r := resources.TestDuckSpec{} + if err := yaml.Unmarshal(y, &r); err != nil { + panic(err) + } + return d.DieFeed(r) +} + +// DieFeedYAMLFile returns a new die loading YAML from a file path. Panics on error. +func (d *TestDuckSpecDie) DieFeedYAMLFile(name string) *TestDuckSpecDie { + y, err := osx.ReadFile(name) + if err != nil { + panic(err) + } + return d.DieFeedYAML(y) +} + +// DieFeedRawExtension returns the resource managed by the die as an raw extension. Panics on error. +func (d *TestDuckSpecDie) DieFeedRawExtension(raw runtime.RawExtension) *TestDuckSpecDie { + j, err := json.Marshal(raw) + if err != nil { + panic(err) + } + return d.DieFeedJSON(j) +} + +// DieRelease returns the resource managed by the die. +func (d *TestDuckSpecDie) DieRelease() resources.TestDuckSpec { + if d.mutable { + return d.r + } + return *d.r.DeepCopy() +} + +// DieReleasePtr returns a pointer to the resource managed by the die. +func (d *TestDuckSpecDie) DieReleasePtr() *resources.TestDuckSpec { + r := d.DieRelease() + return &r +} + +// DieReleaseJSON returns the resource managed by the die as JSON. Panics on error. +func (d *TestDuckSpecDie) DieReleaseJSON() []byte { + r := d.DieReleasePtr() + j, err := json.Marshal(r) + if err != nil { + panic(err) + } + return j +} + +// DieReleaseYAML returns the resource managed by the die as YAML. Panics on error. +func (d *TestDuckSpecDie) DieReleaseYAML() []byte { + r := d.DieReleasePtr() + y, err := yaml.Marshal(r) + if err != nil { + panic(err) + } + return y +} + +// DieReleaseRawExtension returns the resource managed by the die as an raw extension. Panics on error. +func (d *TestDuckSpecDie) DieReleaseRawExtension() runtime.RawExtension { + j := d.DieReleaseJSON() + raw := runtime.RawExtension{} + if err := json.Unmarshal(j, &raw); err != nil { + panic(err) + } + return raw +} + +// DieStamp returns a new die with the resource passed to the callback function. The resource is mutable. +func (d *TestDuckSpecDie) DieStamp(fn func(r *resources.TestDuckSpec)) *TestDuckSpecDie { + r := d.DieRelease() + fn(&r) + return d.DieFeed(r) +} + +// Experimental: DieStampAt uses a JSON path (http://goessner.net/articles/JsonPath/) expression to stamp portions of the resource. The callback is invoked with each JSON path match. Panics if the callback function does not accept a single argument of the same type as found on the resource at the target location. +// +// Future iterations will improve type coercion from the resource to the callback argument. +func (d *TestDuckSpecDie) DieStampAt(jp string, fn interface{}) *TestDuckSpecDie { + return d.DieStamp(func(r *resources.TestDuckSpec) { + cp := jsonpath.New("") + if err := cp.Parse(fmtx.Sprintf("{%s}", jp)); err != nil { + panic(err) + } + cr, err := cp.FindResults(r) + if err != nil { + // errors are expected if a path is not found + return + } + for _, cv := range cr[0] { + args := []reflectx.Value{cv} + reflectx.ValueOf(fn).Call(args) + } + }) +} + +// DeepCopy returns a new die with equivalent state. Useful for snapshotting a mutable die. +func (d *TestDuckSpecDie) DeepCopy() *TestDuckSpecDie { + r := *d.r.DeepCopy() + return &TestDuckSpecDie{ + mutable: d.mutable, + r: r, + } +} + +func (d *TestDuckSpecDie) Fields(v map[string]string) *TestDuckSpecDie { + return d.DieStamp(func(r *resources.TestDuckSpec) { + r.Fields = v + }) +} diff --git a/internal/resources/dies/zz_generated.die_test.go b/internal/resources/dies/zz_generated.die_test.go index 3919c65..6b5b8c3 100644 --- a/internal/resources/dies/zz_generated.die_test.go +++ b/internal/resources/dies/zz_generated.die_test.go @@ -77,3 +77,21 @@ func TestTestResourceNilableStatusDie_MissingMethods(t *testingx.T) { t.Errorf("found missing fields for TestResourceNilableStatusDie: %s", diff.List()) } } + +func TestTestDuckDie_MissingMethods(t *testingx.T) { + die := TestDuckBlank + ignore := []string{"TypeMeta", "ObjectMeta"} + diff := testing.DieFieldDiff(die).Delete(ignore...) + if diff.Len() != 0 { + t.Errorf("found missing fields for TestDuckDie: %s", diff.List()) + } +} + +func TestTestDuckSpecDie_MissingMethods(t *testingx.T) { + die := TestDuckSpecBlank + ignore := []string{} + diff := testing.DieFieldDiff(die).Delete(ignore...) + if diff.Len() != 0 { + t.Errorf("found missing fields for TestDuckSpecDie: %s", diff.List()) + } +} diff --git a/internal/resources/resource.go b/internal/resources/resource.go index 605cd45..1a3ad4f 100644 --- a/internal/resources/resource.go +++ b/internal/resources/resource.go @@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0 package resources import ( + "context" "encoding/json" "fmt" @@ -122,38 +123,18 @@ type TestResourceStatus struct { Fields map[string]string `json:"fields,omitempty"` } -func (rs *TestResourceStatus) InitializeConditions() { - rs.SetConditions([]metav1.Condition{ - { - Type: apis.ConditionReady, - Status: metav1.ConditionUnknown, - Reason: "Initializing", - LastTransitionTime: metav1.Now(), - }, - }) +var condSet = apis.NewLivingConditionSet() + +func (rs *TestResourceStatus) InitializeConditions(ctx context.Context) { + condSet.ManageWithContext(ctx, rs).InitializeConditions() } -func (rs *TestResourceStatus) MarkReady() { - rs.SetConditions([]metav1.Condition{ - { - Type: apis.ConditionReady, - Status: metav1.ConditionTrue, - Reason: "Ready", - LastTransitionTime: metav1.Now(), - }, - }) +func (rs *TestResourceStatus) MarkReady(ctx context.Context) { + condSet.ManageWithContext(ctx, rs).MarkTrue(apis.ConditionReady, "Ready", "") } -func (rs *TestResourceStatus) MarkNotReady(reason, message string, messageA ...interface{}) { - rs.SetConditions([]metav1.Condition{ - { - Type: apis.ConditionReady, - Status: metav1.ConditionFalse, - Reason: reason, - Message: fmt.Sprintf(message, messageA...), - LastTransitionTime: metav1.Now(), - }, - }) +func (rs *TestResourceStatus) MarkNotReady(ctx context.Context, reason, message string, messageA ...interface{}) { + condSet.ManageWithContext(ctx, rs).MarkFalse(apis.ConditionReady, reason, message, messageA...) } // +kubebuilder:object:root=true diff --git a/internal/resources/resource_duck.go b/internal/resources/resource_duck.go new file mode 100644 index 0000000..94fe43e --- /dev/null +++ b/internal/resources/resource_duck.go @@ -0,0 +1,74 @@ +/* +Copyright 2019 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package resources + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var ( + _ client.Object = &TestDuck{} +) + +// +kubebuilder:object:root=true +// +genclient + +type TestDuck struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec TestDuckSpec `json:"spec"` + Status TestResourceStatus `json:"status"` +} + +func (r *TestDuck) Default() { + if r.Spec.Fields == nil { + r.Spec.Fields = map[string]string{} + } + r.Spec.Fields["Defaulter"] = "ran" +} + +func (r *TestDuck) ValidateCreate() (admission.Warnings, error) { + return nil, r.validate().ToAggregate() +} + +func (r *TestDuck) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { + return nil, r.validate().ToAggregate() +} + +func (r *TestDuck) ValidateDelete() (admission.Warnings, error) { + return nil, nil +} + +func (r *TestDuck) validate() field.ErrorList { + errs := field.ErrorList{} + + if r.Spec.Fields != nil { + if _, ok := r.Spec.Fields["invalid"]; ok { + field.Invalid(field.NewPath("spec", "fields", "invalid"), r.Spec.Fields["invalid"], "") + } + } + + return errs +} + +// +kubebuilder:object:generate=true +type TestDuckSpec struct { + Fields map[string]string `json:"fields,omitempty"` +} + +// +kubebuilder:object:root=true + +type TestDuckList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []TestDuck `json:"items"` +} diff --git a/internal/resources/zz_generated.deepcopy.go b/internal/resources/zz_generated.deepcopy.go index 09bb3f4..6479cf4 100644 --- a/internal/resources/zz_generated.deepcopy.go +++ b/internal/resources/zz_generated.deepcopy.go @@ -14,6 +14,87 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestDuck) DeepCopyInto(out *TestDuck) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestDuck. +func (in *TestDuck) DeepCopy() *TestDuck { + if in == nil { + return nil + } + out := new(TestDuck) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestDuck) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestDuckList) DeepCopyInto(out *TestDuckList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TestDuck, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestDuckList. +func (in *TestDuckList) DeepCopy() *TestDuckList { + if in == nil { + return nil + } + out := new(TestDuckList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestDuckList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestDuckSpec) DeepCopyInto(out *TestDuckSpec) { + *out = *in + if in.Fields != nil { + in, out := &in.Fields, &out.Fields + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestDuckSpec. +func (in *TestDuckSpec) DeepCopy() *TestDuckSpec { + if in == nil { + return nil + } + out := new(TestDuckSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TestResource) DeepCopyInto(out *TestResource) { *out = *in diff --git a/reconcilers/aggregate.go b/reconcilers/aggregate.go index a0a299f..20de592 100644 --- a/reconcilers/aggregate.go +++ b/reconcilers/aggregate.go @@ -11,15 +11,19 @@ import ( "fmt" "reflect" "sync" + "time" "github.com/go-logr/logr" apierrs "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 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/duck" "github.com/vmware-labs/reconciler-runtime/internal" + rtime "github.com/vmware-labs/reconciler-runtime/time" "github.com/vmware-labs/reconciler-runtime/tracker" ) @@ -150,7 +154,20 @@ func (r *AggregateReconciler[T]) SetupWithManagerYieldingController(ctx context. return nil, err } - bldr := ctrl.NewControllerManagedBy(mgr).For(r.Type) + bldr := ctrl.NewControllerManagedBy(mgr) + if !duck.IsDuck(r.Type, r.Config.Scheme()) { + bldr.For(r.Type) + } else { + gvk, err := r.Config.GroupVersionKindFor(r.Type) + if err != nil { + return nil, err + } + apiVersion, kind := gvk.ToAPIVersionAndKind() + u := &unstructured.Unstructured{} + u.SetAPIVersion(apiVersion) + u.SetKind(kind) + bldr.For(u) + } if r.Setup != nil { if err := r.Setup(ctx, mgr, bldr); err != nil { return nil, err @@ -196,6 +213,7 @@ func (r *AggregateReconciler[T]) Reconcile(ctx context.Context, req Request) (Re WithValues("resourceType", gvk(r.Type, c.Scheme())) ctx = logr.NewContext(ctx, log) + ctx = rtime.StashNow(ctx, time.Now()) ctx = StashRequest(ctx, req) ctx = StashConfig(ctx, c) ctx = StashOriginalConfig(ctx, r.Config) diff --git a/reconcilers/child_test.go b/reconcilers/child_test.go index 4ee7e1e..79e111d 100644 --- a/reconcilers/child_test.go +++ b/reconcilers/child_test.go @@ -91,17 +91,17 @@ func TestChildReconciler(t *testing.T) { if err != nil { if apierrs.IsAlreadyExists(err) { name := err.(apierrs.APIStatus).Status().Details.Name - parent.Status.MarkNotReady("NameConflict", "%q already exists", name) + parent.Status.MarkNotReady(ctx, "NameConflict", "%q already exists", name) } return } if child == nil { parent.Status.Fields = nil - parent.Status.MarkReady() + parent.Status.MarkReady(ctx) return } parent.Status.Fields = reconcilers.MergeMaps(child.Data) - parent.Status.MarkReady() + parent.Status.MarkReady(ctx) }, } } diff --git a/reconcilers/childset.go b/reconcilers/childset.go index d918c68..aa180ae 100644 --- a/reconcilers/childset.go +++ b/reconcilers/childset.go @@ -20,6 +20,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +var ( + _ SubReconciler[client.Object] = (*ChildSetReconciler[client.Object, client.Object, client.ObjectList])(nil) +) + // 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 diff --git a/reconcilers/childset_test.go b/reconcilers/childset_test.go index a3afcbc..bbfd812 100644 --- a/reconcilers/childset_test.go +++ b/reconcilers/childset_test.go @@ -122,7 +122,7 @@ func TestChildSetReconciler(t *testing.T) { 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) + parent.Status.MarkNotReady(ctx, "NameConflict", "%q already exists", name) } return } @@ -141,7 +141,7 @@ func TestChildSetReconciler(t *testing.T) { if len(parent.Status.Fields) == 0 { parent.Status.Fields = nil } - parent.Status.MarkReady() + parent.Status.MarkReady(ctx) }, } } diff --git a/reconcilers/config.go b/reconcilers/config.go index c3b884d..b9d265b 100644 --- a/reconcilers/config.go +++ b/reconcilers/config.go @@ -22,6 +22,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/cluster" "github.com/go-logr/logr" + "github.com/vmware-labs/reconciler-runtime/duck" "github.com/vmware-labs/reconciler-runtime/tracker" ) @@ -41,8 +42,8 @@ func (c Config) IsEmpty() bool { // 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(), + Client: duck.NewDuckAwareClientWrapper(cluster.GetClient()), + APIReader: duck.NewDuckAwareAPIReaderWrapper(cluster.GetAPIReader(), cluster.GetClient()), Recorder: cluster.GetEventRecorderFor("controller"), Tracker: c.Tracker, } diff --git a/reconcilers/resource.go b/reconcilers/resource.go index 55379e1..366ab3e 100644 --- a/reconcilers/resource.go +++ b/reconcilers/resource.go @@ -11,6 +11,7 @@ import ( "fmt" "reflect" "sync" + "time" "github.com/go-logr/logr" "github.com/google/go-cmp/cmp" @@ -26,7 +27,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "sigs.k8s.io/controller-runtime/pkg/webhook" + "github.com/vmware-labs/reconciler-runtime/duck" "github.com/vmware-labs/reconciler-runtime/internal" + rtime "github.com/vmware-labs/reconciler-runtime/time" ) var ( @@ -108,7 +111,20 @@ func (r *ResourceReconciler[T]) SetupWithManagerYieldingController(ctx context.C return nil, err } - bldr := ctrl.NewControllerManagedBy(mgr).For(r.Type) + bldr := ctrl.NewControllerManagedBy(mgr) + if !duck.IsDuck(r.Type, r.Config.Scheme()) { + bldr.For(r.Type) + } else { + gvk, err := r.Config.GroupVersionKindFor(r.Type) + if err != nil { + return nil, err + } + apiVersion, kind := gvk.ToAPIVersionAndKind() + u := &unstructured.Unstructured{} + u.SetAPIVersion(apiVersion) + u.SetKind(kind) + bldr.For(u) + } if r.Setup != nil { if err := r.Setup(ctx, mgr, bldr); err != nil { return nil, err @@ -149,8 +165,10 @@ func (r *ResourceReconciler[T]) validate(ctx context.Context) error { } 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") + if !hasInitializeConditions || initializeConditionsMethod.Type.NumIn() > 2 || initializeConditionsMethod.Type.NumOut() != 0 { + log.Info("resource status missing InitializeConditions(context.Context) method, conditions will not be auto-initialized") + } else if hasInitializeConditions && initializeConditionsMethod.Type.NumIn() == 1 { + log.Info("resource status InitializeConditions() method is deprecated, use InitializeConditions(context.Context)") } conditionsField, hasConditions := statusType.FieldByName("Conditions") @@ -173,6 +191,7 @@ func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req Request) (Res WithValues("resourceType", gvk(r.Type, c.Scheme())) ctx = logr.NewContext(ctx, log) + ctx = rtime.StashNow(ctx, time.Now()) ctx = StashRequest(ctx, req) ctx = StashConfig(ctx, c) ctx = StashOriginalConfig(ctx, c) @@ -197,7 +216,7 @@ func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req Request) (Res defaulter.Default() } - r.initializeConditions(resource) + r.initializeConditions(ctx, resource) result, err := r.reconcile(ctx, resource) if r.SkipStatusUpdate { @@ -210,16 +229,29 @@ func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req Request) (Res // 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 + if duck.IsDuck(resource, c.Scheme()) { + // patch status + log.Info("patching status", "diff", cmp.Diff(originalResourceStatus, resourceStatus)) + if patchErr := c.Status().Patch(ctx, resource, client.MergeFrom(originalResource)); patchErr != nil { + log.Error(patchErr, "unable to patch status") + c.Recorder.Eventf(resource, corev1.EventTypeWarning, "StatusPatchFailed", + "Failed to patch status: %v", patchErr) + return Result{}, patchErr + } + c.Recorder.Eventf(resource, corev1.EventTypeNormal, "StatusPatched", + "Patched status") + } else { + // 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") } - c.Recorder.Eventf(resource, corev1.EventTypeNormal, "StatusUpdated", - "Updated status") } // return original reconcile result @@ -241,7 +273,7 @@ func (r *ResourceReconciler[T]) reconcile(ctx context.Context, resource T) (Resu return result, nil } -func (r *ResourceReconciler[T]) initializeConditions(obj T) { +func (r *ResourceReconciler[T]) initializeConditions(ctx context.Context, obj T) { status := r.status(obj) if status == nil { return @@ -250,10 +282,17 @@ func (r *ResourceReconciler[T]) initializeConditions(obj T) { if !initializeConditions.IsValid() { return } - if t := initializeConditions.Type(); t.Kind() != reflect.Func || t.NumIn() != 0 || t.NumOut() != 0 { + t := initializeConditions.Type() + if t.Kind() != reflect.Func || t.NumOut() != 0 { + return + } + args := []reflect.Value{} + if t.NumIn() == 1 && t.In(0).AssignableTo(reflect.TypeOf((*context.Context)(nil)).Elem()) { + args = append(args, reflect.ValueOf(ctx)) + } else if t.NumIn() != 0 { return } - initializeConditions.Call([]reflect.Value{}) + initializeConditions.Call(args) } func (r *ResourceReconciler[T]) conditions(obj T) []metav1.Condition { diff --git a/reconcilers/resource_test.go b/reconcilers/resource_test.go index 495d5ea..61c0ff8 100644 --- a/reconcilers/resource_test.go +++ b/reconcilers/resource_test.go @@ -353,6 +353,492 @@ func TestResourceReconciler_Unstructured(t *testing.T) { }) } +func TestResourceReconciler_Duck(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testFinalizer := "test.finalizer" + testRequest := reconcilers.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, + } + + now := metav1.NewTime(time.Now().UTC()).Rfc3339Copy() + nowRfc3339 := now.Format(time.RFC3339) + + 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").LastTransitionTime(now), + ) + }) + 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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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": { + Now: now.Time, + 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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) error { + expected := []metav1.Condition{ + {Type: apis.ConditionReady, Status: metav1.ConditionUnknown, Reason: "Initializing", LastTransitionTime: now}, + } + if diff := cmp.Diff(expected, resource.Status.Conditions); diff != "" { + t.Errorf("Unexpected condition (-expected, +actual): %s", diff) + } + return nil + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusPatched", + `Patched status`), + }, + ExpectStatusPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: resource.GetNamespace(), + Name: resource.GetName(), + SubResource: "status", + PatchType: types.MergePatchType, + Patch: []byte(`{"spec":{"fields":{"Defaulter":"ran"}},"status":{"conditions":[{"lastTransitionTime":"` + nowRfc3339 + `","message":"","reason":"Initializing","status":"Unknown","type":"Ready"}]}}`), + }, + }, + }, + "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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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, "StatusPatched", + `Patched status`), + }, + ExpectStatusPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: resource.GetNamespace(), + Name: resource.GetName(), + SubResource: "status", + PatchType: types.MergePatchType, + Patch: []byte(`{"spec":{"fields":{"Defaulter":"ran"}},"status":{"fields":{"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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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.TestDuck] { + return reconcilers.Sequence[*resources.TestDuck]{ + &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) error { + resource.Status.Fields = map[string]string{ + "want": "this to run", + } + return reconcilers.HaltSubReconcilers + }, + }, + &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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, "StatusPatched", + `Patched status`), + }, + ExpectStatusPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: resource.GetNamespace(), + Name: resource.GetName(), + SubResource: "status", + PatchType: types.MergePatchType, + Patch: []byte(`{"spec":{"fields":{"Defaulter":"ran"}},"status":{"fields":{"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.TestDuck] { + return reconcilers.Sequence[*resources.TestDuck]{ + &reconcilers.SyncReconciler[*resources.TestDuck]{ + SyncWithResult: func(ctx context.Context, resource *resources.TestDuck) (reconcilers.Result, error) { + resource.Status.Fields = map[string]string{ + "want": "this to run", + } + return reconcilers.Result{Requeue: true}, reconcilers.HaltSubReconcilers + }, + }, + &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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, "StatusPatched", + `Patched status`), + }, + ExpectStatusPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: resource.GetNamespace(), + Name: resource.GetName(), + SubResource: "status", + PatchType: types.MergePatchType, + Patch: []byte(`{"spec":{"fields":{"Defaulter":"ran"}},"status":{"fields":{"want":"this to run"}}}`), + }, + }, + ExpectedResult: reconcilers.Result{Requeue: true}, + }, + "status patch failed": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResource{}, + }, + GivenObjects: []client.Object{ + resource, + }, + WithReactors: []rtesting.ReactionFunc{ + rtesting.InduceFailure("patch", "TestResource", rtesting.InduceFailureOpts{ + SubResource: "status", + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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, "StatusPatchFailed", + `Failed to patch status: inducing failure for patch TestResource`), + }, + ExpectStatusPatches: []rtesting.PatchRef{ + { + Group: "testing.reconciler.runtime", + Kind: "TestResource", + Namespace: resource.GetNamespace(), + Name: resource.GetName(), + SubResource: "status", + PatchType: types.MergePatchType, + Patch: []byte(`{"spec":{"fields":{"Defaulter":"ran"}},"status":{"fields":{"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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) error { + if resourceType, ok := reconcilers.RetrieveOriginalResourceType(ctx).(*resources.TestDuck); !ok { + t.Errorf("expected original resource type not in context, found %#v", resourceType) + } + if resourceType, ok := reconcilers.RetrieveResourceType(ctx).(*resources.TestDuck); !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.TestDuck] { + return &reconcilers.SyncReconciler[*resources.TestDuck]{ + Sync: func(ctx context.Context, resource *resources.TestDuck) 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.TestDuck]{ + Type: &resources.TestDuck{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "testing.reconciler.runtime/v1", + Kind: "TestResource", + }, + }, + Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestDuck])(t, c), + SkipStatusUpdate: skipStatusUpdate, + Config: c, + } + }) +} + func TestResourceReconciler(t *testing.T) { testNamespace := "test-namespace" testName := "test-resource" diff --git a/reconcilers/validate_test.go b/reconcilers/validate_test.go index 254e5d0..90bb448 100644 --- a/reconcilers/validate_test.go +++ b/reconcilers/validate_test.go @@ -112,7 +112,7 @@ func TestResourceReconciler_validate_TestResourceEmptyStatus(t *testing.T) { }, expectedLogs: []string{ "resource status missing ObservedGeneration field of type int64, generation will not be managed", - "resource status missing InitializeConditions() method, conditions will not be auto-initialized", + "resource status missing InitializeConditions(context.Context) method, conditions will not be auto-initialized", "resource status is missing field Conditions of type []metav1.Condition, condition timestamps will not be managed", }, }, diff --git a/reconcilers/webhook.go b/reconcilers/webhook.go index b352a20..d573c8b 100644 --- a/reconcilers/webhook.go +++ b/reconcilers/webhook.go @@ -11,9 +11,11 @@ import ( "fmt" "net/http" "sync" + "time" "github.com/go-logr/logr" "github.com/vmware-labs/reconciler-runtime/internal" + rtime "github.com/vmware-labs/reconciler-runtime/time" "gomodules.xyz/jsonpatch/v3" admissionv1 "k8s.io/api/admission/v1" "k8s.io/apimachinery/pkg/api/equality" @@ -124,6 +126,7 @@ func (r *AdmissionWebhookAdapter[T]) Handle(ctx context.Context, req admission.R }, } + ctx = rtime.StashNow(ctx, time.Now()) ctx = StashAdmissionRequest(ctx, req) ctx = StashAdmissionResponse(ctx, resp) diff --git a/testing/config.go b/testing/config.go index afa307d..703250c 100644 --- a/testing/config.go +++ b/testing/config.go @@ -13,6 +13,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/vmware-labs/reconciler-runtime/duck" "github.com/vmware-labs/reconciler-runtime/reconcilers" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/fields" @@ -126,7 +127,7 @@ func (c *ExpectConfig) createClient(objs []client.Object, statusSubResourceTypes builder = c.WithClientBuilder(builder) } - return NewFakeClientWrapper(builder.Build()) + return NewFakeClientWrapper(duck.NewDuckAwareClientWrapper(builder.Build())) } // Config returns the Config object. This method should only be called once. Subsequent calls are diff --git a/testing/reconciler.go b/testing/reconciler.go index 48b5894..9405465 100644 --- a/testing/reconciler.go +++ b/testing/reconciler.go @@ -8,11 +8,13 @@ package testing import ( "context" "testing" + "time" "github.com/go-logr/logr" logrtesting "github.com/go-logr/logr/testing" "github.com/google/go-cmp/cmp" "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtime "github.com/vmware-labs/reconciler-runtime/time" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -101,6 +103,9 @@ type ReconcilerTestCase struct { // It is intended to clean up any state created in the Prepare step or during the test // execution, or to make assertions for mocks. CleanUp func(t *testing.T, ctx context.Context, tc *ReconcilerTestCase) error + // Now is the time the test should run as, defaults to the current time. This value can be used + // by reconcilers via the reconcilers.RetireveNow(ctx) method. + Now time.Time } // VerifyFunc is a verification function for a reconciler's result @@ -135,6 +140,10 @@ func (tc *ReconcilerTestCase) Run(t *testing.T, scheme *runtime.Scheme, factory } ctx := context.Background() + if tc.Now == (time.Time{}) { + tc.Now = time.Now() + } + ctx = rtime.StashNow(ctx, tc.Now) ctx = logr.NewContext(ctx, logrtesting.NewTestLogger(t)) if deadline, ok := t.Deadline(); ok { var cancel context.CancelFunc diff --git a/testing/subreconciler.go b/testing/subreconciler.go index 5716e4a..e72a405 100644 --- a/testing/subreconciler.go +++ b/testing/subreconciler.go @@ -8,6 +8,7 @@ package testing import ( "context" "testing" + "time" "github.com/go-logr/logr" logrtesting "github.com/go-logr/logr/testing" @@ -15,6 +16,7 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/vmware-labs/reconciler-runtime/internal" "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtime "github.com/vmware-labs/reconciler-runtime/time" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -110,6 +112,9 @@ type SubReconcilerTestCase[Type client.Object] struct { // It is intended to clean up any state created in the Prepare step or during the test // execution, or to make assertions for mocks. CleanUp func(t *testing.T, ctx context.Context, tc *SubReconcilerTestCase[Type]) error + // Now is the time the test should run as, defaults to the current time. This value can be used + // by reconcilers via the reconcilers.RetireveNow(ctx) method. + Now time.Time } // SubReconcilerTests represents a map of reconciler test cases. The map key is the name of each @@ -139,6 +144,10 @@ func (tc *SubReconcilerTestCase[T]) Run(t *testing.T, scheme *runtime.Scheme, fa } ctx := reconcilers.WithStash(context.Background()) + if tc.Now == (time.Time{}) { + tc.Now = time.Now() + } + ctx = rtime.StashNow(ctx, tc.Now) ctx = logr.NewContext(ctx, logrtesting.NewTestLogger(t)) if deadline, ok := t.Deadline(); ok { var cancel context.CancelFunc diff --git a/testing/webhook.go b/testing/webhook.go index ae8b5bd..c08acab 100644 --- a/testing/webhook.go +++ b/testing/webhook.go @@ -10,11 +10,13 @@ import ( "net/http" "net/url" "testing" + "time" "github.com/go-logr/logr" logrtesting "github.com/go-logr/logr/testing" "github.com/google/go-cmp/cmp" "github.com/vmware-labs/reconciler-runtime/reconcilers" + rtime "github.com/vmware-labs/reconciler-runtime/time" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -99,6 +101,9 @@ type AdmissionWebhookTestCase struct { // It is intended to clean up any state created in the Prepare step or during the test // execution, or to make assertions for mocks. CleanUp func(t *testing.T, ctx context.Context, tc *AdmissionWebhookTestCase) error + // Now is the time the test should run as, defaults to the current time. This value can be used + // by reconcilers via the reconcilers.RetireveNow(ctx) method. + Now time.Time } // AdmissionWebhookTests represents a map of reconciler test cases. The map key is the name of each @@ -128,6 +133,10 @@ func (tc *AdmissionWebhookTestCase) Run(t *testing.T, scheme *runtime.Scheme, fa } ctx := reconcilers.WithStash(context.Background()) + if tc.Now == (time.Time{}) { + tc.Now = time.Now() + } + ctx = rtime.StashNow(ctx, tc.Now) ctx = logr.NewContext(ctx, logrtesting.NewTestLogger(t)) if deadline, ok := t.Deadline(); ok { var cancel context.CancelFunc diff --git a/time/now.go b/time/now.go new file mode 100644 index 0000000..89987a8 --- /dev/null +++ b/time/now.go @@ -0,0 +1,32 @@ +/* +Copyright 2023 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package time + +import ( + "context" + "time" +) + +type stashKey struct{} + +var nowStashKey = stashKey{} + +func StashNow(ctx context.Context, now time.Time) context.Context { + if ctx.Value(nowStashKey) != nil { + // avoid overwriting + return ctx + } + return context.WithValue(ctx, nowStashKey, &now) +} + +// RetrieveNow returns the stashed time, or the current time if not found. +func RetrieveNow(ctx context.Context) time.Time { + value := ctx.Value(nowStashKey) + if now, ok := value.(*time.Time); ok { + return *now + } + return time.Now() +}