Skip to content

Commit 71e305a

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 d6b3440 commit 71e305a

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
@@ -31,6 +31,8 @@ require (
3131
sigs.k8s.io/yaml v1.4.0
3232
)
3333

34+
require sigs.k8s.io/structured-merge-diff/v4 v4.4.2
35+
3436
require (
3537
cel.dev/expr v0.19.1 // indirect
3638
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
@@ -93,5 +95,4 @@ require (
9395
k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect
9496
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.1 // indirect
9597
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
96-
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
9798
)

pkg/client/fake/client.go

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,17 @@ import (
4545
"k8s.io/apimachinery/pkg/labels"
4646
"k8s.io/apimachinery/pkg/runtime"
4747
"k8s.io/apimachinery/pkg/runtime/schema"
48+
"k8s.io/apimachinery/pkg/runtime/serializer"
4849
"k8s.io/apimachinery/pkg/types"
50+
"k8s.io/apimachinery/pkg/util/managedfields"
4951
utilrand "k8s.io/apimachinery/pkg/util/rand"
5052
"k8s.io/apimachinery/pkg/util/sets"
5153
"k8s.io/apimachinery/pkg/util/strategicpatch"
5254
"k8s.io/apimachinery/pkg/util/validation/field"
5355
"k8s.io/apimachinery/pkg/watch"
56+
clientgoapplyconfigurations "k8s.io/client-go/applyconfigurations"
5457
"k8s.io/client-go/kubernetes/scheme"
58+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
5559
"k8s.io/client-go/testing"
5660
"k8s.io/utils/ptr"
5761

@@ -119,6 +123,7 @@ type ClientBuilder struct {
119123
withStatusSubresource []client.Object
120124
objectTracker testing.ObjectTracker
121125
interceptorFuncs *interceptor.Funcs
126+
typeConverters []managedfields.TypeConverter
122127

123128
// indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK.
124129
// The inner map maps from index name to IndexerFunc.
@@ -160,6 +165,8 @@ func (f *ClientBuilder) WithRuntimeObjects(initRuntimeObjs ...runtime.Object) *C
160165
}
161166

162167
// WithObjectTracker can be optionally used to initialize this fake client with testing.ObjectTracker.
168+
// Setting this is incompatible with setting WithTypeConverters, as they are a setting on the
169+
// tracker.
163170
func (f *ClientBuilder) WithObjectTracker(ot testing.ObjectTracker) *ClientBuilder {
164171
f.objectTracker = ot
165172
return f
@@ -216,6 +223,18 @@ func (f *ClientBuilder) WithInterceptorFuncs(interceptorFuncs interceptor.Funcs)
216223
return f
217224
}
218225

226+
// WithTypeConverters sets the type converters for the fake client. The list is ordered and the first
227+
// non-erroring converter is used.
228+
// This setting is incompatible with WithObjectTracker, as the type converters are a setting on the tracker.
229+
//
230+
// If unset, this defaults to:
231+
// * clientgoapplyconfigurations.NewTypeConverter(clientgoscheme.Scheme),
232+
// * managedfields.NewDeducedTypeConverter(),
233+
func (f *ClientBuilder) WithTypeConverters(typeConverters ...managedfields.TypeConverter) *ClientBuilder {
234+
f.typeConverters = append(f.typeConverters, typeConverters...)
235+
return f
236+
}
237+
219238
// Build builds and returns a new fake client.
220239
func (f *ClientBuilder) Build() client.WithWatch {
221240
if f.scheme == nil {
@@ -236,11 +255,29 @@ func (f *ClientBuilder) Build() client.WithWatch {
236255
withStatusSubResource.Insert(gvk)
237256
}
238257

258+
if f.objectTracker != nil && len(f.typeConverters) > 0 {
259+
panic(errors.New("WithObjectTracker and WithTypeConverters are incompatible"))
260+
}
261+
239262
if f.objectTracker == nil {
240-
tracker = versionedTracker{ObjectTracker: testing.NewObjectTracker(f.scheme, scheme.Codecs.UniversalDecoder()), scheme: f.scheme, withStatusSubresource: withStatusSubResource}
241-
} else {
242-
tracker = versionedTracker{ObjectTracker: f.objectTracker, scheme: f.scheme, withStatusSubresource: withStatusSubResource}
263+
if len(f.typeConverters) == 0 {
264+
f.typeConverters = []managedfields.TypeConverter{
265+
// Use corresponding scheme to ensure the converter error
266+
// for types it can't handle.
267+
clientgoapplyconfigurations.NewTypeConverter(clientgoscheme.Scheme),
268+
managedfields.NewDeducedTypeConverter(),
269+
}
270+
}
271+
f.objectTracker = testing.NewFieldManagedObjectTracker(
272+
f.scheme,
273+
serializer.NewCodecFactory(f.scheme).UniversalDecoder(),
274+
multiTypeConverter{upstream: f.typeConverters},
275+
)
243276
}
277+
tracker = versionedTracker{
278+
ObjectTracker: f.objectTracker,
279+
scheme: f.scheme,
280+
withStatusSubresource: withStatusSubResource}
244281

245282
for _, obj := range f.initObject {
246283
if err := tracker.Add(obj); err != nil {
@@ -901,6 +938,12 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
901938
if err != nil {
902939
return err
903940
}
941+
942+
// otherwise the merge logic in the tracker complains
943+
if patch.Type() == types.ApplyPatchType {
944+
obj.SetManagedFields(nil)
945+
}
946+
904947
data, err := patch.Data(obj)
905948
if err != nil {
906949
return err
@@ -915,7 +958,10 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
915958
defer c.trackerWriteLock.Unlock()
916959
oldObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName())
917960
if err != nil {
918-
return err
961+
if patch.Type() != types.ApplyPatchType {
962+
return err
963+
}
964+
oldObj = &unstructured.Unstructured{}
919965
}
920966
oldAccessor, err := meta.Accessor(oldObj)
921967
if err != nil {
@@ -930,7 +976,7 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
930976
// This ensures that the patch may be rejected if a deletionTimestamp is modified, prior
931977
// to updating the object.
932978
action := testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), data)
933-
o, err := dryPatch(action, c.tracker)
979+
o, err := dryPatch(action, c.tracker, obj)
934980
if err != nil {
935981
return err
936982
}
@@ -989,12 +1035,15 @@ func deletionTimestampEqual(newObj metav1.Object, obj metav1.Object) bool {
9891035
// This results in some code duplication, but was found to be a cleaner alternative than unmarshalling and introspecting the patch data
9901036
// and easier than refactoring the k8s client-go method upstream.
9911037
// Duplicate of upstream: https://github.com/kubernetes/client-go/blob/783d0d33626e59d55d52bfd7696b775851f92107/testing/fixture.go#L146-L194
992-
func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (runtime.Object, error) {
1038+
func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker, newObj runtime.Object) (runtime.Object, error) {
9931039
ns := action.GetNamespace()
9941040
gvr := action.GetResource()
9951041

9961042
obj, err := tracker.Get(gvr, ns, action.GetName())
9971043
if err != nil {
1044+
if action.GetPatchType() == types.ApplyPatchType {
1045+
return &unstructured.Unstructured{}, nil
1046+
}
9981047
return nil, err
9991048
}
10001049

@@ -1039,10 +1088,20 @@ func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (ru
10391088
if err = json.Unmarshal(mergedByte, obj); err != nil {
10401089
return nil, err
10411090
}
1042-
case types.ApplyPatchType:
1043-
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")
10441091
case types.ApplyCBORPatchType:
10451092
return nil, errors.New("apply CBOR patches are not supported in the fake client")
1093+
case types.ApplyPatchType:
1094+
// There doesn't seem to be a way to test this without actually applying it as apply is implemented in the tracker.
1095+
// We have to make sure no reader sees this and we can not handle errors resetting the obj to the original state.
1096+
defer func() {
1097+
if err := tracker.Add(obj); err != nil {
1098+
panic(err)
1099+
}
1100+
}()
1101+
if err := tracker.Apply(gvr, newObj, ns, action.PatchOptions); err != nil {
1102+
return nil, err
1103+
}
1104+
return tracker.Get(gvr, ns, action.GetName())
10461105
default:
10471106
return nil, fmt.Errorf("%s PatchType is not supported", action.GetPatchType())
10481107
}

pkg/client/fake/client_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2516,6 +2516,51 @@ var _ = Describe("Fake client", func() {
25162516
Expect(cl.SubResource(subResourceScale).Update(context.Background(), obj, client.WithSubResourceBody(scale)).Error()).To(Equal(expectedErr))
25172517
})
25182518

2519+
It("supports server-side apply of a client-go resource", func() {
2520+
cl := NewClientBuilder().Build()
2521+
obj := &unstructured.Unstructured{}
2522+
obj.SetAPIVersion("v1")
2523+
obj.SetKind("ConfigMap")
2524+
obj.SetName("foo")
2525+
unstructured.SetNestedField(obj.Object, map[string]any{"some": "data"}, "data")
2526+
2527+
Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed())
2528+
2529+
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "foo"}}
2530+
2531+
Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm)).To(Succeed())
2532+
Expect(cm.Data).To(Equal(map[string]string{"some": "data"}))
2533+
2534+
unstructured.SetNestedField(obj.Object, map[string]any{"other": "data"}, "data")
2535+
Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed())
2536+
2537+
Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(cm), cm)).To(Succeed())
2538+
Expect(cm.Data).To(Equal(map[string]string{"other": "data"}))
2539+
})
2540+
2541+
// It("supports server-side apply of a custom resource", func() {
2542+
// cl := NewClientBuilder().Build()
2543+
// obj := &unstructured.Unstructured{}
2544+
// obj.SetAPIVersion("custom/v1")
2545+
// obj.SetKind("FakeResource")
2546+
// obj.SetName("foo")
2547+
// unstructured.SetNestedField(obj.Object, map[string]any{"some": "data"}, "spec")
2548+
//
2549+
// Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed())
2550+
//
2551+
// result := obj.DeepCopy()
2552+
// unstructured.SetNestedField(result.Object, nil, "spec")
2553+
//
2554+
// Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(result), result)).To(Succeed())
2555+
// Expect(result.Object["spec"]).To(Equal(map[string]any{"some": "data"}))
2556+
//
2557+
// unstructured.SetNestedField(obj.Object, map[string]any{"other": "data"}, "spec")
2558+
// Expect(cl.Patch(context.Background(), obj, client.Apply)).To(Succeed())
2559+
//
2560+
// Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(result), result)).To(Succeed())
2561+
// Expect(result.Object["spec"]).To(Equal(map[string]any{"other": "data"}))
2562+
// })
2563+
25192564
scalableObjs := []client.Object{
25202565
&appsv1.Deployment{
25212566
ObjectMeta: metav1.ObjectMeta{
@@ -2594,6 +2639,7 @@ var _ = Describe("Fake client", func() {
25942639
expected.ResourceVersion = objActual.GetResourceVersion()
25952640
expected.Spec.Replicas = ptr.To(int32(3))
25962641
}
2642+
objExpected.SetManagedFields(objActual.GetManagedFields())
25972643
Expect(cmp.Diff(objExpected, objActual)).To(BeEmpty())
25982644

25992645
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)