diff --git a/go.mod b/go.mod index c72c2dd7d0..0afd10ee4e 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,8 @@ require ( sigs.k8s.io/yaml v1.4.0 ) +require sigs.k8s.io/structured-merge-diff/v4 v4.6.0 + require ( cel.dev/expr v0.19.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect @@ -96,5 +98,4 @@ require ( sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect ) diff --git a/pkg/client/fake/client.go b/pkg/client/fake/client.go index 99124e2f03..43825315b0 100644 --- a/pkg/client/fake/client.go +++ b/pkg/client/fake/client.go @@ -57,12 +57,15 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/managedfields" utilrand "k8s.io/apimachinery/pkg/util/rand" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/strategicpatch" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/watch" + clientgoapplyconfigurations "k8s.io/client-go/applyconfigurations" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/testing" "k8s.io/utils/ptr" @@ -131,6 +134,7 @@ type ClientBuilder struct { withStatusSubresource []client.Object objectTracker testing.ObjectTracker interceptorFuncs *interceptor.Funcs + typeConverter managedfields.TypeConverter // indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK. // The inner map maps from index name to IndexerFunc. @@ -172,6 +176,8 @@ func (f *ClientBuilder) WithRuntimeObjects(initRuntimeObjs ...runtime.Object) *C } // WithObjectTracker can be optionally used to initialize this fake client with testing.ObjectTracker. +// Setting this is incompatible with setting WithTypeConverters, as they are a setting on the +// tracker. func (f *ClientBuilder) WithObjectTracker(ot testing.ObjectTracker) *ClientBuilder { f.objectTracker = ot return f @@ -228,6 +234,25 @@ func (f *ClientBuilder) WithInterceptorFuncs(interceptorFuncs interceptor.Funcs) return f } +// WithTypeConverters sets the type converters for the fake client. The list is ordered and the first +// non-erroring converter is used. +// This setting is incompatible with WithObjectTracker, as the type converters are a setting on the tracker. +// +// If unset, this defaults to: +// * clientgoapplyconfigurations.NewTypeConverter(clientgoscheme.Scheme), +// * managedfields.NewDeducedTypeConverter(), +func (f *ClientBuilder) WithTypeConverters(typeConverters ...managedfields.TypeConverter) *ClientBuilder { + if f.typeConverter == nil { + f.typeConverter = &multiTypeConverter{upstream: typeConverters} + } else if multiTypeConverter, ok := f.typeConverter.(*multiTypeConverter); ok { + multiTypeConverter.upstream = append(multiTypeConverter.upstream, typeConverters...) + } else { + panic(fmt.Sprintf("unexpected type converter already specified: %T; this is incompatible with WithTypeConverters", f.typeConverter)) + } + + return f +} + // Build builds and returns a new fake client. func (f *ClientBuilder) Build() client.WithWatch { if f.scheme == nil { @@ -248,11 +273,31 @@ func (f *ClientBuilder) Build() client.WithWatch { withStatusSubResource.Insert(gvk) } + if f.objectTracker != nil && f.typeConverter != nil { + panic(errors.New("WithObjectTracker and WithTypeConverters are incompatible")) + } + if f.objectTracker == nil { - tracker = versionedTracker{ObjectTracker: testing.NewObjectTracker(f.scheme, scheme.Codecs.UniversalDecoder()), scheme: f.scheme, withStatusSubresource: withStatusSubResource} - } else { - tracker = versionedTracker{ObjectTracker: f.objectTracker, scheme: f.scheme, withStatusSubresource: withStatusSubResource} + if f.typeConverter == nil { + f.typeConverter = &multiTypeConverter{ + upstream: []managedfields.TypeConverter{ + // Use corresponding scheme to ensure the converter error + // for types it can't handle. + clientgoapplyconfigurations.NewTypeConverter(scheme.Scheme), + managedfields.NewDeducedTypeConverter(), + }, + } + } + f.objectTracker = testing.NewFieldManagedObjectTracker( + f.scheme, + serializer.NewCodecFactory(f.scheme).UniversalDecoder(), + f.typeConverter, + ) } + tracker = versionedTracker{ + ObjectTracker: f.objectTracker, + scheme: f.scheme, + withStatusSubresource: withStatusSubResource} for _, obj := range f.initObject { if err := tracker.Add(obj); err != nil { @@ -929,6 +974,12 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client if err != nil { return err } + + // otherwise the merge logic in the tracker complains + if patch.Type() == types.ApplyPatchType { + obj.SetManagedFields(nil) + } + data, err := patch.Data(obj) if err != nil { return err @@ -943,7 +994,10 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client defer c.trackerWriteLock.Unlock() oldObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName()) if err != nil { - return err + if patch.Type() != types.ApplyPatchType { + return err + } + oldObj = &unstructured.Unstructured{} } oldAccessor, err := meta.Accessor(oldObj) if err != nil { @@ -958,7 +1012,7 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client // This ensures that the patch may be rejected if a deletionTimestamp is modified, prior // to updating the object. action := testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), data) - o, err := dryPatch(action, c.tracker) + o, err := dryPatch(action, c.tracker, obj) if err != nil { return err } @@ -1017,12 +1071,15 @@ func deletionTimestampEqual(newObj metav1.Object, obj metav1.Object) bool { // This results in some code duplication, but was found to be a cleaner alternative than unmarshalling and introspecting the patch data // and easier than refactoring the k8s client-go method upstream. // Duplicate of upstream: https://github.com/kubernetes/client-go/blob/783d0d33626e59d55d52bfd7696b775851f92107/testing/fixture.go#L146-L194 -func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (runtime.Object, error) { +func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker, newObj runtime.Object) (runtime.Object, error) { ns := action.GetNamespace() gvr := action.GetResource() obj, err := tracker.Get(gvr, ns, action.GetName()) if err != nil { + if action.GetPatchType() == types.ApplyPatchType { + return &unstructured.Unstructured{}, nil + } return nil, err } @@ -1067,10 +1124,20 @@ func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (ru if err = json.Unmarshal(mergedByte, obj); err != nil { return nil, err } - case types.ApplyPatchType: - return nil, errors.New("apply patches are not supported in the fake client. Follow https://github.com/kubernetes/kubernetes/issues/115598 for the current status") case types.ApplyCBORPatchType: return nil, errors.New("apply CBOR patches are not supported in the fake client") + case types.ApplyPatchType: + // There doesn't seem to be a way to test this without actually applying it as apply is implemented in the tracker. + // We have to make sure no reader sees this and we can not handle errors resetting the obj to the original state. + defer func() { + if err := tracker.Add(obj); err != nil { + panic(err) + } + }() + if err := tracker.Apply(gvr, newObj, ns, action.PatchOptions); err != nil { + return nil, err + } + return tracker.Get(gvr, ns, action.GetName()) default: return nil, fmt.Errorf("%s PatchType is not supported", action.GetPatchType()) } diff --git a/pkg/client/fake/client_test.go b/pkg/client/fake/client_test.go index f6a493f54d..0c531e81a4 100644 --- a/pkg/client/fake/client_test.go +++ b/pkg/client/fake/client_test.go @@ -2516,6 +2516,51 @@ var _ = Describe("Fake client", func() { Expect(cl.SubResource(subResourceScale).Update(context.Background(), obj, client.WithSubResourceBody(scale)).Error()).To(Equal(expectedErr)) }) + It("supports server-side apply of a client-go resource", func() { + cl := NewClientBuilder().Build() + obj := &unstructured.Unstructured{} + obj.SetAPIVersion("v1") + obj.SetKind("ConfigMap") + obj.SetName("foo") + Expect(unstructured.SetNestedField(obj.Object, map[string]any{"some": "data"}, "data")).To(Succeed()) + + Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed()) + + cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "foo"}} + + Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm)).To(Succeed()) + Expect(cm.Data).To(Equal(map[string]string{"some": "data"})) + + Expect(unstructured.SetNestedField(obj.Object, map[string]any{"other": "data"}, "data")).To(Succeed()) + Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed()) + + Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm)).To(Succeed()) + Expect(cm.Data).To(Equal(map[string]string{"other": "data"})) + }) + + // It("supports server-side apply of a custom resource", func() { + // cl := NewClientBuilder().Build() + // obj := &unstructured.Unstructured{} + // obj.SetAPIVersion("custom/v1") + // obj.SetKind("FakeResource") + // obj.SetName("foo") + // unstructured.SetNestedField(obj.Object, map[string]any{"some": "data"}, "spec") + // + // Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed()) + // + // result := obj.DeepCopy() + // unstructured.SetNestedField(result.Object, nil, "spec") + // + // Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(result), result)).To(Succeed()) + // Expect(result.Object["spec"]).To(Equal(map[string]any{"some": "data"})) + // + // unstructured.SetNestedField(obj.Object, map[string]any{"other": "data"}, "spec") + // Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed()) + // + // Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(result), result)).To(Succeed()) + // Expect(result.Object["spec"]).To(Equal(map[string]any{"other": "data"})) + // }) + It("is threadsafe", func() { cl := NewClientBuilder().Build() @@ -2681,6 +2726,7 @@ var _ = Describe("Fake client", func() { expected.ResourceVersion = objActual.GetResourceVersion() expected.Spec.Replicas = ptr.To(int32(3)) } + objExpected.SetManagedFields(objActual.GetManagedFields()) Expect(cmp.Diff(objExpected, objActual)).To(BeEmpty()) scaleActual := &autoscalingv1.Scale{} diff --git a/pkg/client/fake/typeconverter.go b/pkg/client/fake/typeconverter.go new file mode 100644 index 0000000000..ebb55541ae --- /dev/null +++ b/pkg/client/fake/typeconverter.go @@ -0,0 +1,63 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package fake + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/managedfields" + "sigs.k8s.io/structured-merge-diff/v4/typed" +) + +// multiTypeConverter is an implementation detail for the fake client used to +// support server-side apply. +// NOTE: this type should not be exported! +type multiTypeConverter struct { + upstream []managedfields.TypeConverter +} + +func (m multiTypeConverter) ObjectToTyped(r runtime.Object, o ...typed.ValidationOptions) (*typed.TypedValue, error) { + var errs []error + for _, u := range m.upstream { + res, err := u.ObjectToTyped(r, o...) + if err != nil { + errs = append(errs, err) + continue + } + + return res, nil + } + + return nil, fmt.Errorf("failed to convert Object to Typed: %w", kerrors.NewAggregate(errs)) +} + +func (m multiTypeConverter) TypedToObject(v *typed.TypedValue) (runtime.Object, error) { + var errs []error + for _, u := range m.upstream { + res, err := u.TypedToObject(v) + if err != nil { + errs = append(errs, err) + continue + } + + return res, nil + } + + return nil, fmt.Errorf("failed to convert TypedValue to Object: %w", kerrors.NewAggregate(errs)) +}