From e41e6dcbeee8f5a5d2ac3ca5191d9c61a2b33734 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Wed, 6 Mar 2024 02:45:13 -0500 Subject: [PATCH] Allow resources to contain unexported fields (#487) * Allow resources to contain unexported fields Using unexported fields in Kubernetes resources is not common, but does happen. Previously, these fields would cause cmp.Diff to panic. Now, we ignore the content of unexported fields when computing a diff. As the diff is for logging and test assertions this is relatively safe. Semantic equality is used for detecting when a managed resource needs to be updated. Custom equality func can be defined separately as needed. * Review feedback Signed-off-by: Scott Andrews --------- Signed-off-by: Scott Andrews --- internal/resources/dies/dies.go | 66 ++ internal/resources/dies/zz_generated.die.go | 679 ++++++++++++++++++ .../resources/dies/zz_generated.die_test.go | 27 + .../resource_with_unexported_fields.go | 186 +++++ internal/resources/zz_generated.deepcopy.go | 119 +++ reconcilers/child_test.go | 223 ++++++ reconcilers/cmp.go | 24 + reconcilers/cmp_test.go | 83 +++ reconcilers/resource.go | 4 +- reconcilers/resource_test.go | 111 +++ reconcilers/resourcemanager.go | 2 +- testing/config.go | 7 +- testing/subreconciler.go | 4 +- testing/webhook.go | 2 +- 14 files changed, 1528 insertions(+), 9 deletions(-) create mode 100644 internal/resources/resource_with_unexported_fields.go create mode 100644 reconcilers/cmp.go create mode 100644 reconcilers/cmp_test.go diff --git a/internal/resources/dies/dies.go b/internal/resources/dies/dies.go index dea6d16..3877e0e 100644 --- a/internal/resources/dies/dies.go +++ b/internal/resources/dies/dies.go @@ -99,3 +99,69 @@ func (d *TestDuckSpecDie) AddField(key, value string) *TestDuckSpecDie { r.Fields[key] = value }) } + +// +die:object=true +type _ = resources.TestResourceUnexportedFields + +// +die:ignore={unexportedFields} +type _ = resources.TestResourceUnexportedFieldsSpec + +func (d *TestResourceUnexportedFieldsSpecDie) AddField(key, value string) *TestResourceUnexportedFieldsSpecDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { + if r.Fields == nil { + r.Fields = map[string]string{} + } + r.Fields[key] = value + }) +} + +func (d *TestResourceUnexportedFieldsSpecDie) AddUnexportedField(key, value string) *TestResourceUnexportedFieldsSpecDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { + f := r.GetUnexportedFields() + if f == nil { + f = map[string]string{} + } + f[key] = value + r.SetUnexportedFields(f) + }) +} + +func (d *TestResourceUnexportedFieldsSpecDie) TemplateDie(fn func(d *diecorev1.PodTemplateSpecDie)) *TestResourceUnexportedFieldsSpecDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { + d := diecorev1.PodTemplateSpecBlank.DieImmutable(false).DieFeed(r.Template) + fn(d) + r.Template = d.DieRelease() + }) +} + +// +die:ignore={unexportedFields} +type _ = resources.TestResourceUnexportedFieldsStatus + +func (d *TestResourceUnexportedFieldsStatusDie) ConditionsDie(conditions ...*diemetav1.ConditionDie) *TestResourceUnexportedFieldsStatusDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { + r.Conditions = make([]metav1.Condition, len(conditions)) + for i := range conditions { + r.Conditions[i] = conditions[i].DieRelease() + } + }) +} + +func (d *TestResourceUnexportedFieldsStatusDie) AddField(key, value string) *TestResourceUnexportedFieldsStatusDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { + if r.Fields == nil { + r.Fields = map[string]string{} + } + r.Fields[key] = value + }) +} + +func (d *TestResourceUnexportedFieldsStatusDie) AddUnexportedField(key, value string) *TestResourceUnexportedFieldsStatusDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { + f := r.GetUnexportedFields() + if f == nil { + f = map[string]string{} + } + f[key] = value + r.SetUnexportedFields(f) + }) +} diff --git a/internal/resources/dies/zz_generated.die.go b/internal/resources/dies/zz_generated.die.go index 1bfb3e3..a5eb1ba 100644 --- a/internal/resources/dies/zz_generated.die.go +++ b/internal/resources/dies/zz_generated.die.go @@ -2157,3 +2157,682 @@ func (d *TestDuckSpecDie) Fields(v map[string]string) *TestDuckSpecDie { r.Fields = v }) } + +var TestResourceUnexportedFieldsBlank = (&TestResourceUnexportedFieldsDie{}).DieFeed(resources.TestResourceUnexportedFields{}) + +type TestResourceUnexportedFieldsDie struct { + v1.FrozenObjectMeta + mutable bool + r resources.TestResourceUnexportedFields +} + +// DieImmutable returns a new die for the current die's state that is either mutable (`false`) or immutable (`true`). +func (d *TestResourceUnexportedFieldsDie) DieImmutable(immutable bool) *TestResourceUnexportedFieldsDie { + 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 *TestResourceUnexportedFieldsDie) DieFeed(r resources.TestResourceUnexportedFields) *TestResourceUnexportedFieldsDie { + if d.mutable { + d.FrozenObjectMeta = v1.FreezeObjectMeta(r.ObjectMeta) + d.r = r + return d + } + return &TestResourceUnexportedFieldsDie{ + 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 *TestResourceUnexportedFieldsDie) DieFeedPtr(r *resources.TestResourceUnexportedFields) *TestResourceUnexportedFieldsDie { + if r == nil { + r = &resources.TestResourceUnexportedFields{} + } + return d.DieFeed(*r) +} + +// DieFeedJSON returns a new die with the provided JSON. Panics on error. +func (d *TestResourceUnexportedFieldsDie) DieFeedJSON(j []byte) *TestResourceUnexportedFieldsDie { + r := resources.TestResourceUnexportedFields{} + 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 *TestResourceUnexportedFieldsDie) DieFeedYAML(y []byte) *TestResourceUnexportedFieldsDie { + r := resources.TestResourceUnexportedFields{} + 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 *TestResourceUnexportedFieldsDie) DieFeedYAMLFile(name string) *TestResourceUnexportedFieldsDie { + 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 *TestResourceUnexportedFieldsDie) DieFeedRawExtension(raw runtime.RawExtension) *TestResourceUnexportedFieldsDie { + j, err := json.Marshal(raw) + if err != nil { + panic(err) + } + return d.DieFeedJSON(j) +} + +// DieRelease returns the resource managed by the die. +func (d *TestResourceUnexportedFieldsDie) DieRelease() resources.TestResourceUnexportedFields { + if d.mutable { + return d.r + } + return *d.r.DeepCopy() +} + +// DieReleasePtr returns a pointer to the resource managed by the die. +func (d *TestResourceUnexportedFieldsDie) DieReleasePtr() *resources.TestResourceUnexportedFields { + r := d.DieRelease() + return &r +} + +// DieReleaseUnstructured returns the resource managed by the die as an unstructured object. Panics on error. +func (d *TestResourceUnexportedFieldsDie) 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 *TestResourceUnexportedFieldsDie) 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 *TestResourceUnexportedFieldsDie) 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 *TestResourceUnexportedFieldsDie) 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 *TestResourceUnexportedFieldsDie) DieStamp(fn func(r *resources.TestResourceUnexportedFields)) *TestResourceUnexportedFieldsDie { + 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 or a pointer to that 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 *TestResourceUnexportedFieldsDie) DieStampAt(jp string, fn interface{}) *TestResourceUnexportedFieldsDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFields) { + if ni := reflectx.ValueOf(fn).Type().NumIn(); ni != 1 { + panic(fmtx.Errorf("callback function must have 1 input parameters, found %d", ni)) + } + if no := reflectx.ValueOf(fn).Type().NumOut(); no != 0 { + panic(fmtx.Errorf("callback function must have 0 output parameters, found %d", no)) + } + + 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] { + arg0t := reflectx.ValueOf(fn).Type().In(0) + + var args []reflectx.Value + if cv.Type().AssignableTo(arg0t) { + args = []reflectx.Value{cv} + } else if cv.CanAddr() && cv.Addr().Type().AssignableTo(arg0t) { + args = []reflectx.Value{cv.Addr()} + } else { + panic(fmtx.Errorf("callback function must accept value of type %q, found type %q", cv.Type(), arg0t)) + } + + reflectx.ValueOf(fn).Call(args) + } + }) +} + +// DieWith returns a new die after passing the current die to the callback function. The passed die is mutable. +func (d *TestResourceUnexportedFieldsDie) DieWith(fns ...func(d *TestResourceUnexportedFieldsDie)) *TestResourceUnexportedFieldsDie { + nd := TestResourceUnexportedFieldsBlank.DieFeed(d.DieRelease()).DieImmutable(false) + for _, fn := range fns { + if fn != nil { + fn(nd) + } + } + return d.DieFeed(nd.DieRelease()) +} + +// DeepCopy returns a new die with equivalent state. Useful for snapshotting a mutable die. +func (d *TestResourceUnexportedFieldsDie) DeepCopy() *TestResourceUnexportedFieldsDie { + r := *d.r.DeepCopy() + return &TestResourceUnexportedFieldsDie{ + FrozenObjectMeta: v1.FreezeObjectMeta(r.ObjectMeta), + mutable: d.mutable, + r: r, + } +} + +var _ runtime.Object = (*TestResourceUnexportedFieldsDie)(nil) + +func (d *TestResourceUnexportedFieldsDie) DeepCopyObject() runtime.Object { + return d.r.DeepCopy() +} + +func (d *TestResourceUnexportedFieldsDie) GetObjectKind() schema.ObjectKind { + r := d.DieRelease() + return r.GetObjectKind() +} + +func (d *TestResourceUnexportedFieldsDie) MarshalJSON() ([]byte, error) { + return json.Marshal(d.r) +} + +func (d *TestResourceUnexportedFieldsDie) UnmarshalJSON(b []byte) error { + if d == TestResourceUnexportedFieldsBlank { + 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.TestResourceUnexportedFields{} + 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 *TestResourceUnexportedFieldsDie) APIVersion(v string) *TestResourceUnexportedFieldsDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFields) { + 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 *TestResourceUnexportedFieldsDie) Kind(v string) *TestResourceUnexportedFieldsDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFields) { + r.Kind = v + }) +} + +// MetadataDie stamps the resource's ObjectMeta field with a mutable die. +func (d *TestResourceUnexportedFieldsDie) MetadataDie(fn func(d *v1.ObjectMetaDie)) *TestResourceUnexportedFieldsDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFields) { + 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 *TestResourceUnexportedFieldsDie) SpecDie(fn func(d *TestResourceUnexportedFieldsSpecDie)) *TestResourceUnexportedFieldsDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFields) { + d := TestResourceUnexportedFieldsSpecBlank.DieImmutable(false).DieFeed(r.Spec) + fn(d) + r.Spec = d.DieRelease() + }) +} + +// StatusDie stamps the resource's status field with a mutable die. +func (d *TestResourceUnexportedFieldsDie) StatusDie(fn func(d *TestResourceUnexportedFieldsStatusDie)) *TestResourceUnexportedFieldsDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFields) { + d := TestResourceUnexportedFieldsStatusBlank.DieImmutable(false).DieFeed(r.Status) + fn(d) + r.Status = d.DieRelease() + }) +} + +func (d *TestResourceUnexportedFieldsDie) Spec(v resources.TestResourceUnexportedFieldsSpec) *TestResourceUnexportedFieldsDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFields) { + r.Spec = v + }) +} + +func (d *TestResourceUnexportedFieldsDie) Status(v resources.TestResourceUnexportedFieldsStatus) *TestResourceUnexportedFieldsDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFields) { + r.Status = v + }) +} + +var TestResourceUnexportedFieldsSpecBlank = (&TestResourceUnexportedFieldsSpecDie{}).DieFeed(resources.TestResourceUnexportedFieldsSpec{}) + +type TestResourceUnexportedFieldsSpecDie struct { + mutable bool + r resources.TestResourceUnexportedFieldsSpec +} + +// DieImmutable returns a new die for the current die's state that is either mutable (`false`) or immutable (`true`). +func (d *TestResourceUnexportedFieldsSpecDie) DieImmutable(immutable bool) *TestResourceUnexportedFieldsSpecDie { + 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 *TestResourceUnexportedFieldsSpecDie) DieFeed(r resources.TestResourceUnexportedFieldsSpec) *TestResourceUnexportedFieldsSpecDie { + if d.mutable { + d.r = r + return d + } + return &TestResourceUnexportedFieldsSpecDie{ + 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 *TestResourceUnexportedFieldsSpecDie) DieFeedPtr(r *resources.TestResourceUnexportedFieldsSpec) *TestResourceUnexportedFieldsSpecDie { + if r == nil { + r = &resources.TestResourceUnexportedFieldsSpec{} + } + return d.DieFeed(*r) +} + +// DieFeedJSON returns a new die with the provided JSON. Panics on error. +func (d *TestResourceUnexportedFieldsSpecDie) DieFeedJSON(j []byte) *TestResourceUnexportedFieldsSpecDie { + r := resources.TestResourceUnexportedFieldsSpec{} + 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 *TestResourceUnexportedFieldsSpecDie) DieFeedYAML(y []byte) *TestResourceUnexportedFieldsSpecDie { + r := resources.TestResourceUnexportedFieldsSpec{} + 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 *TestResourceUnexportedFieldsSpecDie) DieFeedYAMLFile(name string) *TestResourceUnexportedFieldsSpecDie { + 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 *TestResourceUnexportedFieldsSpecDie) DieFeedRawExtension(raw runtime.RawExtension) *TestResourceUnexportedFieldsSpecDie { + j, err := json.Marshal(raw) + if err != nil { + panic(err) + } + return d.DieFeedJSON(j) +} + +// DieRelease returns the resource managed by the die. +func (d *TestResourceUnexportedFieldsSpecDie) DieRelease() resources.TestResourceUnexportedFieldsSpec { + if d.mutable { + return d.r + } + return *d.r.DeepCopy() +} + +// DieReleasePtr returns a pointer to the resource managed by the die. +func (d *TestResourceUnexportedFieldsSpecDie) DieReleasePtr() *resources.TestResourceUnexportedFieldsSpec { + r := d.DieRelease() + return &r +} + +// DieReleaseJSON returns the resource managed by the die as JSON. Panics on error. +func (d *TestResourceUnexportedFieldsSpecDie) 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 *TestResourceUnexportedFieldsSpecDie) 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 *TestResourceUnexportedFieldsSpecDie) 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 *TestResourceUnexportedFieldsSpecDie) DieStamp(fn func(r *resources.TestResourceUnexportedFieldsSpec)) *TestResourceUnexportedFieldsSpecDie { + 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 or a pointer to that 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 *TestResourceUnexportedFieldsSpecDie) DieStampAt(jp string, fn interface{}) *TestResourceUnexportedFieldsSpecDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { + if ni := reflectx.ValueOf(fn).Type().NumIn(); ni != 1 { + panic(fmtx.Errorf("callback function must have 1 input parameters, found %d", ni)) + } + if no := reflectx.ValueOf(fn).Type().NumOut(); no != 0 { + panic(fmtx.Errorf("callback function must have 0 output parameters, found %d", no)) + } + + 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] { + arg0t := reflectx.ValueOf(fn).Type().In(0) + + var args []reflectx.Value + if cv.Type().AssignableTo(arg0t) { + args = []reflectx.Value{cv} + } else if cv.CanAddr() && cv.Addr().Type().AssignableTo(arg0t) { + args = []reflectx.Value{cv.Addr()} + } else { + panic(fmtx.Errorf("callback function must accept value of type %q, found type %q", cv.Type(), arg0t)) + } + + reflectx.ValueOf(fn).Call(args) + } + }) +} + +// DieWith returns a new die after passing the current die to the callback function. The passed die is mutable. +func (d *TestResourceUnexportedFieldsSpecDie) DieWith(fns ...func(d *TestResourceUnexportedFieldsSpecDie)) *TestResourceUnexportedFieldsSpecDie { + nd := TestResourceUnexportedFieldsSpecBlank.DieFeed(d.DieRelease()).DieImmutable(false) + for _, fn := range fns { + if fn != nil { + fn(nd) + } + } + return d.DieFeed(nd.DieRelease()) +} + +// DeepCopy returns a new die with equivalent state. Useful for snapshotting a mutable die. +func (d *TestResourceUnexportedFieldsSpecDie) DeepCopy() *TestResourceUnexportedFieldsSpecDie { + r := *d.r.DeepCopy() + return &TestResourceUnexportedFieldsSpecDie{ + mutable: d.mutable, + r: r, + } +} + +func (d *TestResourceUnexportedFieldsSpecDie) Fields(v map[string]string) *TestResourceUnexportedFieldsSpecDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { + r.Fields = v + }) +} + +func (d *TestResourceUnexportedFieldsSpecDie) Template(v corev1.PodTemplateSpec) *TestResourceUnexportedFieldsSpecDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { + r.Template = v + }) +} + +func (d *TestResourceUnexportedFieldsSpecDie) ErrOnMarshal(v bool) *TestResourceUnexportedFieldsSpecDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { + r.ErrOnMarshal = v + }) +} + +func (d *TestResourceUnexportedFieldsSpecDie) ErrOnUnmarshal(v bool) *TestResourceUnexportedFieldsSpecDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsSpec) { + r.ErrOnUnmarshal = v + }) +} + +var TestResourceUnexportedFieldsStatusBlank = (&TestResourceUnexportedFieldsStatusDie{}).DieFeed(resources.TestResourceUnexportedFieldsStatus{}) + +type TestResourceUnexportedFieldsStatusDie struct { + mutable bool + r resources.TestResourceUnexportedFieldsStatus +} + +// DieImmutable returns a new die for the current die's state that is either mutable (`false`) or immutable (`true`). +func (d *TestResourceUnexportedFieldsStatusDie) DieImmutable(immutable bool) *TestResourceUnexportedFieldsStatusDie { + 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 *TestResourceUnexportedFieldsStatusDie) DieFeed(r resources.TestResourceUnexportedFieldsStatus) *TestResourceUnexportedFieldsStatusDie { + if d.mutable { + d.r = r + return d + } + return &TestResourceUnexportedFieldsStatusDie{ + 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 *TestResourceUnexportedFieldsStatusDie) DieFeedPtr(r *resources.TestResourceUnexportedFieldsStatus) *TestResourceUnexportedFieldsStatusDie { + if r == nil { + r = &resources.TestResourceUnexportedFieldsStatus{} + } + return d.DieFeed(*r) +} + +// DieFeedJSON returns a new die with the provided JSON. Panics on error. +func (d *TestResourceUnexportedFieldsStatusDie) DieFeedJSON(j []byte) *TestResourceUnexportedFieldsStatusDie { + r := resources.TestResourceUnexportedFieldsStatus{} + 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 *TestResourceUnexportedFieldsStatusDie) DieFeedYAML(y []byte) *TestResourceUnexportedFieldsStatusDie { + r := resources.TestResourceUnexportedFieldsStatus{} + 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 *TestResourceUnexportedFieldsStatusDie) DieFeedYAMLFile(name string) *TestResourceUnexportedFieldsStatusDie { + 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 *TestResourceUnexportedFieldsStatusDie) DieFeedRawExtension(raw runtime.RawExtension) *TestResourceUnexportedFieldsStatusDie { + j, err := json.Marshal(raw) + if err != nil { + panic(err) + } + return d.DieFeedJSON(j) +} + +// DieRelease returns the resource managed by the die. +func (d *TestResourceUnexportedFieldsStatusDie) DieRelease() resources.TestResourceUnexportedFieldsStatus { + if d.mutable { + return d.r + } + return *d.r.DeepCopy() +} + +// DieReleasePtr returns a pointer to the resource managed by the die. +func (d *TestResourceUnexportedFieldsStatusDie) DieReleasePtr() *resources.TestResourceUnexportedFieldsStatus { + r := d.DieRelease() + return &r +} + +// DieReleaseJSON returns the resource managed by the die as JSON. Panics on error. +func (d *TestResourceUnexportedFieldsStatusDie) 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 *TestResourceUnexportedFieldsStatusDie) 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 *TestResourceUnexportedFieldsStatusDie) 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 *TestResourceUnexportedFieldsStatusDie) DieStamp(fn func(r *resources.TestResourceUnexportedFieldsStatus)) *TestResourceUnexportedFieldsStatusDie { + 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 or a pointer to that 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 *TestResourceUnexportedFieldsStatusDie) DieStampAt(jp string, fn interface{}) *TestResourceUnexportedFieldsStatusDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { + if ni := reflectx.ValueOf(fn).Type().NumIn(); ni != 1 { + panic(fmtx.Errorf("callback function must have 1 input parameters, found %d", ni)) + } + if no := reflectx.ValueOf(fn).Type().NumOut(); no != 0 { + panic(fmtx.Errorf("callback function must have 0 output parameters, found %d", no)) + } + + 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] { + arg0t := reflectx.ValueOf(fn).Type().In(0) + + var args []reflectx.Value + if cv.Type().AssignableTo(arg0t) { + args = []reflectx.Value{cv} + } else if cv.CanAddr() && cv.Addr().Type().AssignableTo(arg0t) { + args = []reflectx.Value{cv.Addr()} + } else { + panic(fmtx.Errorf("callback function must accept value of type %q, found type %q", cv.Type(), arg0t)) + } + + reflectx.ValueOf(fn).Call(args) + } + }) +} + +// DieWith returns a new die after passing the current die to the callback function. The passed die is mutable. +func (d *TestResourceUnexportedFieldsStatusDie) DieWith(fns ...func(d *TestResourceUnexportedFieldsStatusDie)) *TestResourceUnexportedFieldsStatusDie { + nd := TestResourceUnexportedFieldsStatusBlank.DieFeed(d.DieRelease()).DieImmutable(false) + for _, fn := range fns { + if fn != nil { + fn(nd) + } + } + return d.DieFeed(nd.DieRelease()) +} + +// DeepCopy returns a new die with equivalent state. Useful for snapshotting a mutable die. +func (d *TestResourceUnexportedFieldsStatusDie) DeepCopy() *TestResourceUnexportedFieldsStatusDie { + r := *d.r.DeepCopy() + return &TestResourceUnexportedFieldsStatusDie{ + mutable: d.mutable, + r: r, + } +} + +func (d *TestResourceUnexportedFieldsStatusDie) Status(v apis.Status) *TestResourceUnexportedFieldsStatusDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { + r.Status = v + }) +} + +func (d *TestResourceUnexportedFieldsStatusDie) Fields(v map[string]string) *TestResourceUnexportedFieldsStatusDie { + return d.DieStamp(func(r *resources.TestResourceUnexportedFieldsStatus) { + r.Fields = v + }) +} diff --git a/internal/resources/dies/zz_generated.die_test.go b/internal/resources/dies/zz_generated.die_test.go index 6b5b8c3..7fa28c8 100644 --- a/internal/resources/dies/zz_generated.die_test.go +++ b/internal/resources/dies/zz_generated.die_test.go @@ -95,3 +95,30 @@ func TestTestDuckSpecDie_MissingMethods(t *testingx.T) { t.Errorf("found missing fields for TestDuckSpecDie: %s", diff.List()) } } + +func TestTestResourceUnexportedFieldsDie_MissingMethods(t *testingx.T) { + die := TestResourceUnexportedFieldsBlank + ignore := []string{"TypeMeta", "ObjectMeta"} + diff := testing.DieFieldDiff(die).Delete(ignore...) + if diff.Len() != 0 { + t.Errorf("found missing fields for TestResourceUnexportedFieldsDie: %s", diff.List()) + } +} + +func TestTestResourceUnexportedFieldsSpecDie_MissingMethods(t *testingx.T) { + die := TestResourceUnexportedFieldsSpecBlank + ignore := []string{"unexportedFields"} + diff := testing.DieFieldDiff(die).Delete(ignore...) + if diff.Len() != 0 { + t.Errorf("found missing fields for TestResourceUnexportedFieldsSpecDie: %s", diff.List()) + } +} + +func TestTestResourceUnexportedFieldsStatusDie_MissingMethods(t *testingx.T) { + die := TestResourceUnexportedFieldsStatusBlank + ignore := []string{"unexportedFields"} + diff := testing.DieFieldDiff(die).Delete(ignore...) + if diff.Len() != 0 { + t.Errorf("found missing fields for TestResourceUnexportedFieldsStatusDie: %s", diff.List()) + } +} diff --git a/internal/resources/resource_with_unexported_fields.go b/internal/resources/resource_with_unexported_fields.go new file mode 100644 index 0000000..73625a4 --- /dev/null +++ b/internal/resources/resource_with_unexported_fields.go @@ -0,0 +1,186 @@ +/* +Copyright 2022 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package resources + +import ( + "encoding/json" + "fmt" + + "github.com/vmware-labs/reconciler-runtime/apis" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + 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" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var ( + _ webhook.Defaulter = &TestResourceUnexportedFields{} + _ webhook.Validator = &TestResourceUnexportedFields{} + _ client.Object = &TestResourceUnexportedFields{} +) + +// +kubebuilder:object:root=true +// +genclient + +type TestResourceUnexportedFields struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec TestResourceUnexportedFieldsSpec `json:"spec"` + Status TestResourceUnexportedFieldsStatus `json:"status"` +} + +func (r *TestResourceUnexportedFields) Default() { + if r.Spec.Fields == nil { + r.Spec.Fields = map[string]string{} + } + r.Spec.Fields["Defaulter"] = "ran" +} + +func (r *TestResourceUnexportedFields) ValidateCreate() (admission.Warnings, error) { + return nil, r.validate().ToAggregate() +} + +func (r *TestResourceUnexportedFields) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { + return nil, r.validate().ToAggregate() +} + +func (r *TestResourceUnexportedFields) ValidateDelete() (admission.Warnings, error) { + return nil, nil +} + +func (r *TestResourceUnexportedFields) 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 +} + +func (r *TestResourceUnexportedFields) ReflectUnexportedFieldsToStatus() { + r.Status.unexportedFields = r.Spec.unexportedFields +} + +// +kubebuilder:object:generate=true +type TestResourceUnexportedFieldsSpec struct { + Fields map[string]string `json:"fields,omitempty"` + unexportedFields map[string]string + Template corev1.PodTemplateSpec `json:"template,omitempty"` + + ErrOnMarshal bool `json:"errOnMarhsal,omitempty"` + ErrOnUnmarshal bool `json:"errOnUnmarhsal,omitempty"` +} + +func (r *TestResourceUnexportedFieldsSpec) GetUnexportedFields() map[string]string { + return r.unexportedFields +} + +func (r *TestResourceUnexportedFieldsSpec) SetUnexportedFields(f map[string]string) { + r.unexportedFields = f +} + +func (r *TestResourceUnexportedFieldsSpec) AddUnexportedField(key, value string) { + if r.unexportedFields == nil { + r.unexportedFields = map[string]string{} + } + r.unexportedFields[key] = value +} + +func (r *TestResourceUnexportedFieldsSpec) MarshalJSON() ([]byte, error) { + if r.ErrOnMarshal { + return nil, fmt.Errorf("ErrOnMarshal true") + } + return json.Marshal(&struct { + Fields map[string]string `json:"fields,omitempty"` + Template corev1.PodTemplateSpec `json:"template,omitempty"` + ErrOnMarshal bool `json:"errOnMarshal,omitempty"` + ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` + }{ + Fields: r.Fields, + Template: r.Template, + ErrOnMarshal: r.ErrOnMarshal, + ErrOnUnmarshal: r.ErrOnUnmarshal, + }) +} + +func (r *TestResourceUnexportedFieldsSpec) UnmarshalJSON(data []byte) error { + type alias struct { + Fields map[string]string `json:"fields,omitempty"` + Template corev1.PodTemplateSpec `json:"template,omitempty"` + ErrOnMarshal bool `json:"errOnMarshal,omitempty"` + ErrOnUnmarshal bool `json:"errOnUnmarshal,omitempty"` + } + a := &alias{} + if err := json.Unmarshal(data, a); err != nil { + return err + } + r.Fields = a.Fields + r.Template = a.Template + r.ErrOnMarshal = a.ErrOnMarshal + r.ErrOnUnmarshal = a.ErrOnUnmarshal + if r.ErrOnUnmarshal { + return fmt.Errorf("ErrOnUnmarshal true") + } + return nil +} + +// +kubebuilder:object:generate=true +type TestResourceUnexportedFieldsStatus struct { + apis.Status `json:",inline"` + Fields map[string]string `json:"fields,omitempty"` + unexportedFields map[string]string +} + +func (r *TestResourceUnexportedFieldsStatus) GetUnexportedFields() map[string]string { + return r.unexportedFields +} + +func (r *TestResourceUnexportedFieldsStatus) SetUnexportedFields(f map[string]string) { + r.unexportedFields = f +} + +func (r *TestResourceUnexportedFieldsStatus) AddUnexportedField(key, value string) { + if r.unexportedFields == nil { + r.unexportedFields = map[string]string{} + } + r.unexportedFields[key] = value +} + +// +kubebuilder:object:root=true + +type TestResourceUnexportedFieldsList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + Items []TestResourceUnexportedFields `json:"items"` +} + +func init() { + SchemeBuilder.Register(&TestResourceUnexportedFields{}, &TestResourceUnexportedFieldsList{}) + + if err := equality.Semantic.AddFuncs( + func(a, b TestResourceUnexportedFieldsSpec) bool { + return equality.Semantic.DeepEqual(a.Fields, b.Fields) && + equality.Semantic.DeepEqual(a.Template, b.Template) && + equality.Semantic.DeepEqual(a.ErrOnMarshal, b.ErrOnMarshal) && + equality.Semantic.DeepEqual(a.ErrOnUnmarshal, b.ErrOnUnmarshal) + }, + func(a, b TestResourceUnexportedFieldsStatus) bool { + return equality.Semantic.DeepEqual(a.Status, b.Status) && + equality.Semantic.DeepEqual(a.Fields, b.Fields) + }, + ); err != nil { + panic(err) + } +} diff --git a/internal/resources/zz_generated.deepcopy.go b/internal/resources/zz_generated.deepcopy.go index 4899372..9415212 100644 --- a/internal/resources/zz_generated.deepcopy.go +++ b/internal/resources/zz_generated.deepcopy.go @@ -459,3 +459,122 @@ func (in *TestResourceStatus) DeepCopy() *TestResourceStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResourceUnexportedFields) DeepCopyInto(out *TestResourceUnexportedFields) { + *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 TestResourceUnexportedFields. +func (in *TestResourceUnexportedFields) DeepCopy() *TestResourceUnexportedFields { + if in == nil { + return nil + } + out := new(TestResourceUnexportedFields) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestResourceUnexportedFields) 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 *TestResourceUnexportedFieldsList) DeepCopyInto(out *TestResourceUnexportedFieldsList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TestResourceUnexportedFields, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceUnexportedFieldsList. +func (in *TestResourceUnexportedFieldsList) DeepCopy() *TestResourceUnexportedFieldsList { + if in == nil { + return nil + } + out := new(TestResourceUnexportedFieldsList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TestResourceUnexportedFieldsList) 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 *TestResourceUnexportedFieldsSpec) DeepCopyInto(out *TestResourceUnexportedFieldsSpec) { + *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 + } + } + if in.unexportedFields != nil { + in, out := &in.unexportedFields, &out.unexportedFields + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + in.Template.DeepCopyInto(&out.Template) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResourceUnexportedFieldsSpec. +func (in *TestResourceUnexportedFieldsSpec) DeepCopy() *TestResourceUnexportedFieldsSpec { + if in == nil { + return nil + } + out := new(TestResourceUnexportedFieldsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TestResourceUnexportedFieldsStatus) DeepCopyInto(out *TestResourceUnexportedFieldsStatus) { + *out = *in + in.Status.DeepCopyInto(&out.Status) + 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 + } + } + if in.unexportedFields != nil { + in, out := &in.unexportedFields, &out.unexportedFields + *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 TestResourceUnexportedFieldsStatus. +func (in *TestResourceUnexportedFieldsStatus) DeepCopy() *TestResourceUnexportedFieldsStatus { + if in == nil { + return nil + } + out := new(TestResourceUnexportedFieldsStatus) + in.DeepCopyInto(out) + return out +} diff --git a/reconcilers/child_test.go b/reconcilers/child_test.go index 3532bdf..169a673 100644 --- a/reconcilers/child_test.go +++ b/reconcilers/child_test.go @@ -2108,3 +2108,226 @@ func TestChildReconciler_Unstructured(t *testing.T) { return rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*unstructured.Unstructured])(t, c) }) } + +func TestChildReconciler_UnexportedFields(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + + 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"), + ) + }) + + childCreate := dies.TestResourceUnexportedFieldsBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + d.ControlledBy(resource, scheme) + }) + childGiven := childCreate. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.CreationTimestamp(now) + }) + + defaultChildReconciler := func(c reconcilers.Config) *reconcilers.ChildReconciler[*resources.TestResource, *resources.TestResourceUnexportedFields, *resources.TestResourceUnexportedFieldsList] { + return &reconcilers.ChildReconciler[*resources.TestResource, *resources.TestResourceUnexportedFields, *resources.TestResourceUnexportedFieldsList]{ + DesiredChild: func(ctx context.Context, parent *resources.TestResource) (*resources.TestResourceUnexportedFields, error) { + if len(parent.Spec.Fields) == 0 { + return nil, nil + } + + return &resources.TestResourceUnexportedFields{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: parent.Namespace, + Name: parent.Name, + }, + Spec: resources.TestResourceUnexportedFieldsSpec{ + Fields: parent.Spec.Fields, + Template: parent.Spec.Template, + ErrOnMarshal: parent.Spec.ErrOnMarshal, + ErrOnUnmarshal: parent.Spec.ErrOnUnmarshal, + }, + }, nil + }, + MergeBeforeUpdate: func(current, desired *resources.TestResourceUnexportedFields) { + current.Spec.Fields = desired.Spec.Fields + current.Spec.Template = desired.Spec.Template + current.Spec.ErrOnMarshal = desired.Spec.ErrOnMarshal + current.Spec.ErrOnUnmarshal = desired.Spec.ErrOnUnmarshal + }, + ReflectChildStatusOnParent: func(ctx context.Context, parent *resources.TestResource, child *resources.TestResourceUnexportedFields, err error) { + if err != nil { + switch { + case apierrs.IsAlreadyExists(err): + name := err.(apierrs.APIStatus).Status().Details.Name + parent.Status.MarkNotReady(ctx, "NameConflict", "%q already exists", name) + case apierrs.IsInvalid(err): + name := err.(apierrs.APIStatus).Status().Details.Name + parent.Status.MarkNotReady(ctx, "InvalidChild", "%q was rejected by the api server", name) + } + return + } + if child == nil { + parent.Status.Fields = nil + parent.Status.MarkReady(ctx) + return + } + parent.Status.Fields = child.Status.Fields + parent.Status.MarkReady(ctx) + }, + } + } + + rts := rtesting.SubReconcilerTests[*resources.TestResource]{ + "child is in sync": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + childGiven. + SpecDie(func(d *dies.TestResourceUnexportedFieldsSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceUnexportedFieldsStatusDie) { + d.AddField("foo", "bar") + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + }, + "update status": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + childGiven. + SpecDie(func(d *dies.TestResourceUnexportedFieldsSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceUnexportedFieldsStatusDie) { + d.AddField("foo", "bar") + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + StatusDie(func(d *dies.TestResourceStatusDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + }, + "create child": { + Resource: resource. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Created", + `Created TestResourceUnexportedFields %q`, testName), + }, + ExpectResource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + }). + DieReleasePtr(), + ExpectCreates: []client.Object{ + childCreate. + SpecDie(func(d *dies.TestResourceUnexportedFieldsSpecDie) { + d.AddField("foo", "bar") + }), + }, + }, + "update child": { + Resource: resourceReady. + SpecDie(func(d *dies.TestResourceSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }). + DieReleasePtr(), + GivenObjects: []client.Object{ + childGiven. + SpecDie(func(d *dies.TestResourceUnexportedFieldsSpecDie) { + d.AddField("foo", "bar") + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Updated", + `Updated TestResourceUnexportedFields %q`, testName), + }, + ExpectUpdates: []client.Object{ + childGiven. + SpecDie(func(d *dies.TestResourceUnexportedFieldsSpecDie) { + d.AddField("foo", "bar") + d.AddField("new", "field") + }), + }, + }, + "delete child": { + Resource: resourceReady.DieReleasePtr(), + GivenObjects: []client.Object{ + childGiven, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResource] { + return defaultChildReconciler(c) + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "Deleted", + `Deleted TestResourceUnexportedFields %q`, testName), + }, + ExpectDeletes: []rtesting.DeleteRef{ + rtesting.NewDeleteRefFromObject(childGiven, scheme), + }, + }, + } + + 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/cmp.go b/reconcilers/cmp.go new file mode 100644 index 0000000..2f6e7a7 --- /dev/null +++ b/reconcilers/cmp.go @@ -0,0 +1,24 @@ +/* +Copyright 2024 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers + +import ( + "unicode" + "unicode/utf8" + + "github.com/google/go-cmp/cmp" +) + +// IgnoreAllUnexported is a cmp.Option that ignores unexported fields in all structs +var IgnoreAllUnexported = cmp.FilterPath(func(p cmp.Path) bool { + // from cmp.IgnoreUnexported with type info removed + sf, ok := p.Index(-1).(cmp.StructField) + if !ok { + return false + } + r, _ := utf8.DecodeRuneInString(sf.Name()) + return !unicode.IsUpper(r) +}, cmp.Ignore()) diff --git a/reconcilers/cmp_test.go b/reconcilers/cmp_test.go new file mode 100644 index 0000000..ceaed11 --- /dev/null +++ b/reconcilers/cmp_test.go @@ -0,0 +1,83 @@ +/* +Copyright 2024 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package reconcilers_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/vmware-labs/reconciler-runtime/internal/resources" + "github.com/vmware-labs/reconciler-runtime/internal/resources/dies" + "github.com/vmware-labs/reconciler-runtime/reconcilers" +) + +type TestResourceUnexportedSpec struct { + spec resources.TestResourceUnexportedFieldsSpec +} + +func TestIgnoreAllUnexported(t *testing.T) { + tests := map[string]struct { + a interface{} + b interface{} + shouldDiff bool + }{ + "nil is equivalent": { + a: nil, + b: nil, + shouldDiff: false, + }, + "different exported fields have a difference": { + a: dies.TestResourceUnexportedFieldsSpecBlank. + AddField("name", "hello"). + DieRelease(), + b: dies.TestResourceUnexportedFieldsSpecBlank. + AddField("name", "world"). + DieRelease(), + shouldDiff: true, + }, + "different unexported fields do not have a difference": { + a: dies.TestResourceUnexportedFieldsSpecBlank. + AddUnexportedField("name", "hello"). + DieRelease(), + b: dies.TestResourceUnexportedFieldsSpecBlank. + AddUnexportedField("name", "world"). + DieRelease(), + shouldDiff: false, + }, + "different exported fields nested in an unexported field do not have a difference": { + a: TestResourceUnexportedSpec{ + spec: dies.TestResourceUnexportedFieldsSpecBlank. + AddField("name", "hello"). + DieRelease(), + }, + b: TestResourceUnexportedSpec{ + spec: dies.TestResourceUnexportedFieldsSpecBlank. + AddField("name", "world"). + DieRelease(), + }, + shouldDiff: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if name[0:1] == "#" { + t.SkipNow() + } + + diff := cmp.Diff(tc.a, tc.b, reconcilers.IgnoreAllUnexported) + hasDiff := diff != "" + shouldDiff := tc.shouldDiff + + if !hasDiff && shouldDiff { + t.Errorf("expected equality, found diff") + } + if hasDiff && !shouldDiff { + t.Errorf("found diff, expected equality: %s", diff) + } + }) + } +} diff --git a/reconcilers/resource.go b/reconcilers/resource.go index 67b8d90..4e0157d 100644 --- a/reconcilers/resource.go +++ b/reconcilers/resource.go @@ -233,7 +233,7 @@ func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req Request) (Res if !equality.Semantic.DeepEqual(resourceStatus, originalResourceStatus) && resource.GetDeletionTimestamp() == nil { if duck.IsDuck(resource, c.Scheme()) { // patch status - log.Info("patching status", "diff", cmp.Diff(originalResourceStatus, resourceStatus)) + log.Info("patching status", "diff", cmp.Diff(originalResourceStatus, resourceStatus, IgnoreAllUnexported)) if patchErr := c.Status().Patch(ctx, resource, client.MergeFrom(originalResource)); patchErr != nil { if !errors.Is(patchErr, ErrQuiet) { log.Error(patchErr, "unable to patch status") @@ -246,7 +246,7 @@ func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req Request) (Res "Patched status") } else { // update status - log.Info("updating status", "diff", cmp.Diff(originalResourceStatus, resourceStatus)) + log.Info("updating status", "diff", cmp.Diff(originalResourceStatus, resourceStatus, IgnoreAllUnexported)) if updateErr := c.Status().Update(ctx, resource); updateErr != nil { if !errors.Is(updateErr, ErrQuiet) { log.Error(updateErr, "unable to update status") diff --git a/reconcilers/resource_test.go b/reconcilers/resource_test.go index fc9e95f..a967d48 100644 --- a/reconcilers/resource_test.go +++ b/reconcilers/resource_test.go @@ -1282,3 +1282,114 @@ func TestResourceReconciler(t *testing.T) { } }) } + +func TestResourceReconciler_UnexportedFields(t *testing.T) { + testNamespace := "test-namespace" + testName := "test-resource" + testRequest := reconcilers.Request{ + NamespacedName: types.NamespacedName{Namespace: testNamespace, Name: testName}, + } + + scheme := runtime.NewScheme() + _ = resources.AddToScheme(scheme) + + resource := dies.TestResourceUnexportedFieldsBlank. + MetadataDie(func(d *diemetav1.ObjectMetaDie) { + d.Namespace(testNamespace) + d.Name(testName) + }). + StatusDie(func(d *dies.TestResourceUnexportedFieldsStatusDie) { + d.ConditionsDie( + diemetav1.ConditionBlank.Type(apis.ConditionReady).Status(metav1.ConditionUnknown).Reason("Initializing"), + ) + }) + + rts := rtesting.ReconcilerTests{ + "mutated exported and unexported status": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResourceUnexportedFields{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceUnexportedFields] { + return &reconcilers.SyncReconciler[*resources.TestResourceUnexportedFields]{ + Sync: func(ctx context.Context, resource *resources.TestResourceUnexportedFields) error { + if resource.Status.Fields == nil { + resource.Status.Fields = map[string]string{} + } + resource.ReflectUnexportedFieldsToStatus() + resource.Status.Fields["Reconciler"] = "ran" + resource.Status.AddUnexportedField("Reconciler", "ran") + return nil + }, + } + }, + }, + ExpectEvents: []rtesting.Event{ + rtesting.NewEvent(resource, scheme, corev1.EventTypeNormal, "StatusUpdated", + `Updated status`), + }, + ExpectStatusUpdates: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceUnexportedFieldsStatusDie) { + d.AddField("Reconciler", "ran") + d.AddUnexportedField("Reconciler", "ran") + }), + }, + }, + "mutated unexported status": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResourceUnexportedFields{}, + }, + GivenObjects: []client.Object{ + resource, + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceUnexportedFields] { + return &reconcilers.SyncReconciler[*resources.TestResourceUnexportedFields]{ + Sync: func(ctx context.Context, resource *resources.TestResourceUnexportedFields) error { + if resource.Status.Fields == nil { + resource.Status.Fields = map[string]string{} + } + resource.ReflectUnexportedFieldsToStatus() + resource.Status.AddUnexportedField("Reconciler", "ran") + return nil + }, + } + }, + }, + }, + "no mutated status": { + Request: testRequest, + StatusSubResourceTypes: []client.Object{ + &resources.TestResourceUnexportedFields{}, + }, + GivenObjects: []client.Object{ + resource.StatusDie(func(d *dies.TestResourceUnexportedFieldsStatusDie) { + d.AddUnexportedField("Test", "ran") + d.AddUnexportedField("Reconciler", "ran") + }), + }, + Metadata: map[string]interface{}{ + "SubReconciler": func(t *testing.T, c reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceUnexportedFields] { + return &reconcilers.SyncReconciler[*resources.TestResourceUnexportedFields]{ + Sync: func(ctx context.Context, resource *resources.TestResourceUnexportedFields) error { + resource.Status.AddUnexportedField("Reconciler", "ran") + return nil + }, + } + }, + }, + }, + } + + rts.Run(t, scheme, func(t *testing.T, rtc *rtesting.ReconcilerTestCase, c reconcilers.Config) reconcile.Reconciler { + return &reconcilers.ResourceReconciler[*resources.TestResourceUnexportedFields]{ + Reconciler: rtc.Metadata["SubReconciler"].(func(*testing.T, reconcilers.Config) reconcilers.SubReconciler[*resources.TestResourceUnexportedFields])(t, c), + Config: c, + } + }) +} diff --git a/reconcilers/resourcemanager.go b/reconcilers/resourcemanager.go index 94f901d..b44f93c 100644 --- a/reconcilers/resourcemanager.go +++ b/reconcilers/resourcemanager.go @@ -203,7 +203,7 @@ func (r *ResourceManager[T]) Manage(ctx context.Context, resource client.Object, log.Info("resource is in sync, no update required") return actual, nil } - log.Info("updating resource", "diff", cmp.Diff(r.sanitize(actual), r.sanitize(current))) + log.Info("updating resource", "diff", cmp.Diff(r.sanitize(actual), r.sanitize(current), IgnoreAllUnexported)) if err := c.Update(ctx, current); err != nil { if !errors.Is(err, ErrQuiet) { log.Error(err, "unable to update resource", "resource", namespaceName(current)) diff --git a/testing/config.go b/testing/config.go index f5e4c07..460f944 100644 --- a/testing/config.go +++ b/testing/config.go @@ -191,7 +191,7 @@ func (c *ExpectConfig) AssertClientCreateExpectations(t *testing.T) { } c.init() - c.compareActions(t, "Create", c.ExpectCreates, c.client.CreateActions, IgnoreLastTransitionTime, SafeDeployDiff, IgnoreTypeMeta, IgnoreCreationTimestamp, IgnoreResourceVersion, cmpopts.EquateEmpty()) + c.compareActions(t, "Create", c.ExpectCreates, c.client.CreateActions, reconcilers.IgnoreAllUnexported, IgnoreLastTransitionTime, IgnoreTypeMeta, IgnoreCreationTimestamp, IgnoreResourceVersion, cmpopts.EquateEmpty()) } // AssertClientUpdateExpectations asserts observed reconciler client update behavior matches the expected client update behavior @@ -201,7 +201,7 @@ func (c *ExpectConfig) AssertClientUpdateExpectations(t *testing.T) { } c.init() - c.compareActions(t, "Update", c.ExpectUpdates, c.client.UpdateActions, IgnoreLastTransitionTime, SafeDeployDiff, IgnoreTypeMeta, IgnoreCreationTimestamp, IgnoreResourceVersion, cmpopts.EquateEmpty()) + c.compareActions(t, "Update", c.ExpectUpdates, c.client.UpdateActions, reconcilers.IgnoreAllUnexported, IgnoreLastTransitionTime, IgnoreTypeMeta, IgnoreCreationTimestamp, IgnoreResourceVersion, cmpopts.EquateEmpty()) } // AssertClientPatchExpectations asserts observed reconciler client patch behavior matches the expected client patch behavior @@ -286,7 +286,7 @@ func (c *ExpectConfig) AssertClientStatusUpdateExpectations(t *testing.T) { } c.init() - c.compareActions(t, "StatusUpdate", c.ExpectStatusUpdates, c.client.StatusUpdateActions, statusSubresourceOnly, IgnoreLastTransitionTime, SafeDeployDiff, cmpopts.EquateEmpty()) + c.compareActions(t, "StatusUpdate", c.ExpectStatusUpdates, c.client.StatusUpdateActions, statusSubresourceOnly, reconcilers.IgnoreAllUnexported, IgnoreLastTransitionTime, cmpopts.EquateEmpty()) } // AssertClientStatusPatchExpectations asserts observed reconciler client status patch behavior matches the expected client status patch behavior @@ -427,6 +427,7 @@ var ( return str != "" && !strings.HasPrefix(str, "Status") }, cmp.Ignore()) + // Deprecated: use reconcilers.IgnoreAllUnexported instead SafeDeployDiff = cmpopts.IgnoreUnexported(resource.Quantity{}) NormalizeLabelSelector = cmp.Transformer("labels.Selector", func(s labels.Selector) *string { diff --git a/testing/subreconciler.go b/testing/subreconciler.go index d8fac64..2298d88 100644 --- a/testing/subreconciler.go +++ b/testing/subreconciler.go @@ -164,7 +164,7 @@ func (tc *SubReconcilerTestCase[T]) Run(t *testing.T, scheme *runtime.Scheme, fa // Set func for verifying stashed values if tc.VerifyStashedValue == nil { tc.VerifyStashedValue = func(t *testing.T, key reconcilers.StashKey, expected, actual interface{}) { - if diff := cmp.Diff(expected, actual, IgnoreLastTransitionTime, SafeDeployDiff, IgnoreTypeMeta, IgnoreCreationTimestamp, IgnoreResourceVersion, cmpopts.EquateEmpty()); diff != "" { + if diff := cmp.Diff(expected, actual, reconcilers.IgnoreAllUnexported, IgnoreLastTransitionTime, IgnoreTypeMeta, IgnoreCreationTimestamp, IgnoreResourceVersion, cmpopts.EquateEmpty()); diff != "" { t.Errorf("ExpectStashedValues[%q] differs (%s, %s): %s", key, DiffRemovedColor.Sprint("-expected"), DiffAddedColor.Sprint("+actual"), ColorizeDiff(diff)) } } @@ -279,7 +279,7 @@ func (tc *SubReconcilerTestCase[T]) Run(t *testing.T, scheme *runtime.Scheme, fa // mirror defaulting of the resource expectedResource.SetResourceVersion("999") } - if diff := cmp.Diff(expectedResource, resource, IgnoreLastTransitionTime, SafeDeployDiff, IgnoreTypeMeta, cmpopts.EquateEmpty()); diff != "" { + if diff := cmp.Diff(expectedResource, resource, reconcilers.IgnoreAllUnexported, IgnoreLastTransitionTime, IgnoreTypeMeta, cmpopts.EquateEmpty()); diff != "" { t.Errorf("ExpectResource differs (%s, %s): %s", DiffRemovedColor.Sprint("-expected"), DiffAddedColor.Sprint("+actual"), ColorizeDiff(diff)) } diff --git a/testing/webhook.go b/testing/webhook.go index 082dfdf..a2fc08f 100644 --- a/testing/webhook.go +++ b/testing/webhook.go @@ -216,7 +216,7 @@ func (tc *AdmissionWebhookTestCase) Run(t *testing.T, scheme *runtime.Scheme, fa }() tc.ExpectedResponse.Complete(*tc.Request) - if diff := cmp.Diff(tc.ExpectedResponse, response); diff != "" { + if diff := cmp.Diff(tc.ExpectedResponse, response, reconcilers.IgnoreAllUnexported); diff != "" { t.Errorf("ExpectedResponse differs (%s, %s): %s", DiffRemovedColor.Sprint("-expected"), DiffAddedColor.Sprint("+actual"), ColorizeDiff(diff)) }