Skip to content

Commit a61e2ad

Browse files
committed
:warn: Fakeclient: Add apply support
This change adds apply support into the fake client. This relies on the upstream support for this which is implemented in a new [FieldManagedObjectTracker][0]. In order to support many types, a custom `multiTypeConverter` is added. [0]: https://github.com/kubernetes/kubernetes/blob/4dc7a48ac6fb631a84e1974772bf7b8fd0bb9c59/staging/src/k8s.io/client-go/testing/fixture.go#L643
1 parent 71f7db5 commit a61e2ad

File tree

4 files changed

+175
-9
lines changed

4 files changed

+175
-9
lines changed

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ require (
3232
sigs.k8s.io/yaml v1.4.0
3333
)
3434

35+
require sigs.k8s.io/structured-merge-diff/v4 v4.6.0
36+
3537
require (
3638
cel.dev/expr v0.19.1 // indirect
3739
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
@@ -96,5 +98,4 @@ require (
9698
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect
9799
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
98100
sigs.k8s.io/randfill v1.0.0 // indirect
99-
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
100101
)

pkg/client/fake/client.go

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,18 @@ import (
5656
"k8s.io/apimachinery/pkg/labels"
5757
"k8s.io/apimachinery/pkg/runtime"
5858
"k8s.io/apimachinery/pkg/runtime/schema"
59+
"k8s.io/apimachinery/pkg/runtime/serializer"
5960
"k8s.io/apimachinery/pkg/types"
6061
"k8s.io/apimachinery/pkg/util/json"
62+
"k8s.io/apimachinery/pkg/util/managedfields"
6163
utilrand "k8s.io/apimachinery/pkg/util/rand"
6264
"k8s.io/apimachinery/pkg/util/sets"
6365
"k8s.io/apimachinery/pkg/util/strategicpatch"
6466
"k8s.io/apimachinery/pkg/util/validation/field"
6567
"k8s.io/apimachinery/pkg/watch"
68+
clientgoapplyconfigurations "k8s.io/client-go/applyconfigurations"
6669
"k8s.io/client-go/kubernetes/scheme"
70+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
6771
"k8s.io/client-go/testing"
6872
"k8s.io/utils/ptr"
6973

@@ -131,6 +135,7 @@ type ClientBuilder struct {
131135
withStatusSubresource []client.Object
132136
objectTracker testing.ObjectTracker
133137
interceptorFuncs *interceptor.Funcs
138+
typeConverters []managedfields.TypeConverter
134139

135140
// indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK.
136141
// The inner map maps from index name to IndexerFunc.
@@ -172,6 +177,8 @@ func (f *ClientBuilder) WithRuntimeObjects(initRuntimeObjs ...runtime.Object) *C
172177
}
173178

174179
// WithObjectTracker can be optionally used to initialize this fake client with testing.ObjectTracker.
180+
// Setting this is incompatible with setting WithTypeConverters, as they are a setting on the
181+
// tracker.
175182
func (f *ClientBuilder) WithObjectTracker(ot testing.ObjectTracker) *ClientBuilder {
176183
f.objectTracker = ot
177184
return f
@@ -228,6 +235,18 @@ func (f *ClientBuilder) WithInterceptorFuncs(interceptorFuncs interceptor.Funcs)
228235
return f
229236
}
230237

238+
// WithTypeConverters sets the type converters for the fake client. The list is ordered and the first
239+
// non-erroring converter is used.
240+
// This setting is incompatible with WithObjectTracker, as the type converters are a setting on the tracker.
241+
//
242+
// If unset, this defaults to:
243+
// * clientgoapplyconfigurations.NewTypeConverter(clientgoscheme.Scheme),
244+
// * managedfields.NewDeducedTypeConverter(),
245+
func (f *ClientBuilder) WithTypeConverters(typeConverters ...managedfields.TypeConverter) *ClientBuilder {
246+
f.typeConverters = append(f.typeConverters, typeConverters...)
247+
return f
248+
}
249+
231250
// Build builds and returns a new fake client.
232251
func (f *ClientBuilder) Build() client.WithWatch {
233252
if f.scheme == nil {
@@ -248,11 +267,29 @@ func (f *ClientBuilder) Build() client.WithWatch {
248267
withStatusSubResource.Insert(gvk)
249268
}
250269

270+
if f.objectTracker != nil && len(f.typeConverters) > 0 {
271+
panic(errors.New("WithObjectTracker and WithTypeConverters are incompatible"))
272+
}
273+
251274
if f.objectTracker == nil {
252-
tracker = versionedTracker{ObjectTracker: testing.NewObjectTracker(f.scheme, scheme.Codecs.UniversalDecoder()), scheme: f.scheme, withStatusSubresource: withStatusSubResource}
253-
} else {
254-
tracker = versionedTracker{ObjectTracker: f.objectTracker, scheme: f.scheme, withStatusSubresource: withStatusSubResource}
275+
if len(f.typeConverters) == 0 {
276+
f.typeConverters = []managedfields.TypeConverter{
277+
// Use corresponding scheme to ensure the converter error
278+
// for types it can't handle.
279+
clientgoapplyconfigurations.NewTypeConverter(clientgoscheme.Scheme),
280+
managedfields.NewDeducedTypeConverter(),
281+
}
282+
}
283+
f.objectTracker = testing.NewFieldManagedObjectTracker(
284+
f.scheme,
285+
serializer.NewCodecFactory(f.scheme).UniversalDecoder(),
286+
multiTypeConverter{upstream: f.typeConverters},
287+
)
255288
}
289+
tracker = versionedTracker{
290+
ObjectTracker: f.objectTracker,
291+
scheme: f.scheme,
292+
withStatusSubresource: withStatusSubResource}
256293

257294
for _, obj := range f.initObject {
258295
if err := tracker.Add(obj); err != nil {
@@ -926,6 +963,12 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
926963
if err != nil {
927964
return err
928965
}
966+
967+
// otherwise the merge logic in the tracker complains
968+
if patch.Type() == types.ApplyPatchType {
969+
obj.SetManagedFields(nil)
970+
}
971+
929972
data, err := patch.Data(obj)
930973
if err != nil {
931974
return err
@@ -940,7 +983,10 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
940983
defer c.trackerWriteLock.Unlock()
941984
oldObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName())
942985
if err != nil {
943-
return err
986+
if patch.Type() != types.ApplyPatchType {
987+
return err
988+
}
989+
oldObj = &unstructured.Unstructured{}
944990
}
945991
oldAccessor, err := meta.Accessor(oldObj)
946992
if err != nil {
@@ -955,7 +1001,7 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
9551001
// This ensures that the patch may be rejected if a deletionTimestamp is modified, prior
9561002
// to updating the object.
9571003
action := testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), data)
958-
o, err := dryPatch(action, c.tracker)
1004+
o, err := dryPatch(action, c.tracker, obj)
9591005
if err != nil {
9601006
return err
9611007
}
@@ -1014,12 +1060,15 @@ func deletionTimestampEqual(newObj metav1.Object, obj metav1.Object) bool {
10141060
// This results in some code duplication, but was found to be a cleaner alternative than unmarshalling and introspecting the patch data
10151061
// and easier than refactoring the k8s client-go method upstream.
10161062
// Duplicate of upstream: https://github.com/kubernetes/client-go/blob/783d0d33626e59d55d52bfd7696b775851f92107/testing/fixture.go#L146-L194
1017-
func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (runtime.Object, error) {
1063+
func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker, newObj runtime.Object) (runtime.Object, error) {
10181064
ns := action.GetNamespace()
10191065
gvr := action.GetResource()
10201066

10211067
obj, err := tracker.Get(gvr, ns, action.GetName())
10221068
if err != nil {
1069+
if action.GetPatchType() == types.ApplyPatchType {
1070+
return &unstructured.Unstructured{}, nil
1071+
}
10231072
return nil, err
10241073
}
10251074

@@ -1064,10 +1113,20 @@ func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (ru
10641113
if err = json.Unmarshal(mergedByte, obj); err != nil {
10651114
return nil, err
10661115
}
1067-
case types.ApplyPatchType:
1068-
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")
10691116
case types.ApplyCBORPatchType:
10701117
return nil, errors.New("apply CBOR patches are not supported in the fake client")
1118+
case types.ApplyPatchType:
1119+
// There doesn't seem to be a way to test this without actually applying it as apply is implemented in the tracker.
1120+
// We have to make sure no reader sees this and we can not handle errors resetting the obj to the original state.
1121+
defer func() {
1122+
if err := tracker.Add(obj); err != nil {
1123+
panic(err)
1124+
}
1125+
}()
1126+
if err := tracker.Apply(gvr, newObj, ns, action.PatchOptions); err != nil {
1127+
return nil, err
1128+
}
1129+
return tracker.Get(gvr, ns, action.GetName())
10711130
default:
10721131
return nil, fmt.Errorf("%s PatchType is not supported", action.GetPatchType())
10731132
}

pkg/client/fake/client_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2653,6 +2653,51 @@ var _ = Describe("Fake client", func() {
26532653
wg.Wait()
26542654
})
26552655

2656+
It("supports server-side apply of a client-go resource", func() {
2657+
cl := NewClientBuilder().Build()
2658+
obj := &unstructured.Unstructured{}
2659+
obj.SetAPIVersion("v1")
2660+
obj.SetKind("ConfigMap")
2661+
obj.SetName("foo")
2662+
unstructured.SetNestedField(obj.Object, map[string]any{"some": "data"}, "data")
2663+
2664+
Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed())
2665+
2666+
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}
2667+
2668+
Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm)).To(Succeed())
2669+
Expect(cm.Data).To(Equal(map[string]string{"some": "data"}))
2670+
2671+
unstructured.SetNestedField(obj.Object, map[string]any{"other": "data"}, "data")
2672+
Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed())
2673+
2674+
Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm)).To(Succeed())
2675+
Expect(cm.Data).To(Equal(map[string]string{"other": "data"}))
2676+
})
2677+
2678+
// It("supports server-side apply of a custom resource", func() {
2679+
// cl := NewClientBuilder().Build()
2680+
// obj := &unstructured.Unstructured{}
2681+
// obj.SetAPIVersion("custom/v1")
2682+
// obj.SetKind("FakeResource")
2683+
// obj.SetName("foo")
2684+
// unstructured.SetNestedField(obj.Object, map[string]any{"some": "data"}, "spec")
2685+
//
2686+
// Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed())
2687+
//
2688+
// result := obj.DeepCopy()
2689+
// unstructured.SetNestedField(result.Object, nil, "spec")
2690+
//
2691+
// Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(result), result)).To(Succeed())
2692+
// Expect(result.Object["spec"]).To(Equal(map[string]any{"some": "data"}))
2693+
//
2694+
// unstructured.SetNestedField(obj.Object, map[string]any{"other": "data"}, "spec")
2695+
// Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed())
2696+
//
2697+
// Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(result), result)).To(Succeed())
2698+
// Expect(result.Object["spec"]).To(Equal(map[string]any{"other": "data"}))
2699+
// })
2700+
26562701
scalableObjs := []client.Object{
26572702
&appsv1.Deployment{
26582703
ObjectMeta: metav1.ObjectMeta{
@@ -2731,6 +2776,7 @@ var _ = Describe("Fake client", func() {
27312776
expected.ResourceVersion = objActual.GetResourceVersion()
27322777
expected.Spec.Replicas = ptr.To(int32(3))
27332778
}
2779+
objExpected.SetManagedFields(objActual.GetManagedFields())
27342780
Expect(cmp.Diff(objExpected, objActual)).To(BeEmpty())
27352781

27362782
scaleActual := &autoscalingv1.Scale{}

pkg/client/fake/typeconverter.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package fake
18+
19+
import (
20+
"fmt"
21+
22+
"k8s.io/apimachinery/pkg/runtime"
23+
utilerror "k8s.io/apimachinery/pkg/util/errors"
24+
"k8s.io/apimachinery/pkg/util/managedfields"
25+
"sigs.k8s.io/structured-merge-diff/v4/typed"
26+
)
27+
28+
type multiTypeConverter struct {
29+
upstream []managedfields.TypeConverter
30+
}
31+
32+
func (m multiTypeConverter) ObjectToTyped(r runtime.Object, o ...typed.ValidationOptions) (*typed.TypedValue, error) {
33+
var errs []error
34+
for _, u := range m.upstream {
35+
res, err := u.ObjectToTyped(r, o...)
36+
if err != nil {
37+
errs = append(errs, err)
38+
continue
39+
}
40+
41+
return res, nil
42+
}
43+
44+
return nil, fmt.Errorf("failed to convert Object to Typed: %w", utilerror.NewAggregate(errs))
45+
}
46+
47+
func (m multiTypeConverter) TypedToObject(v *typed.TypedValue) (runtime.Object, error) {
48+
var errs []error
49+
for _, u := range m.upstream {
50+
res, err := u.TypedToObject(v)
51+
if err != nil {
52+
errs = append(errs, err)
53+
continue
54+
}
55+
56+
return res, nil
57+
}
58+
59+
return nil, fmt.Errorf("failed to convert TypedValue to Object: %w", utilerror.NewAggregate(errs))
60+
}

0 commit comments

Comments
 (0)