Skip to content

Commit b3f4310

Browse files
alvaroalemantomasaschan
authored andcommitted
: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 6ad5c1d commit b3f4310

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
@@ -57,13 +57,17 @@ import (
5757
"k8s.io/apimachinery/pkg/labels"
5858
"k8s.io/apimachinery/pkg/runtime"
5959
"k8s.io/apimachinery/pkg/runtime/schema"
60+
"k8s.io/apimachinery/pkg/runtime/serializer"
6061
"k8s.io/apimachinery/pkg/types"
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 {
@@ -929,6 +966,12 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
929966
if err != nil {
930967
return err
931968
}
969+
970+
// otherwise the merge logic in the tracker complains
971+
if patch.Type() == types.ApplyPatchType {
972+
obj.SetManagedFields(nil)
973+
}
974+
932975
data, err := patch.Data(obj)
933976
if err != nil {
934977
return err
@@ -943,7 +986,10 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
943986
defer c.trackerWriteLock.Unlock()
944987
oldObj, err := c.tracker.Get(gvr, accessor.GetNamespace(), accessor.GetName())
945988
if err != nil {
946-
return err
989+
if patch.Type() != types.ApplyPatchType {
990+
return err
991+
}
992+
oldObj = &unstructured.Unstructured{}
947993
}
948994
oldAccessor, err := meta.Accessor(oldObj)
949995
if err != nil {
@@ -958,7 +1004,7 @@ func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client
9581004
// This ensures that the patch may be rejected if a deletionTimestamp is modified, prior
9591005
// to updating the object.
9601006
action := testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), data)
961-
o, err := dryPatch(action, c.tracker)
1007+
o, err := dryPatch(action, c.tracker, obj)
9621008
if err != nil {
9631009
return err
9641010
}
@@ -1017,12 +1063,15 @@ func deletionTimestampEqual(newObj metav1.Object, obj metav1.Object) bool {
10171063
// This results in some code duplication, but was found to be a cleaner alternative than unmarshalling and introspecting the patch data
10181064
// and easier than refactoring the k8s client-go method upstream.
10191065
// Duplicate of upstream: https://github.com/kubernetes/client-go/blob/783d0d33626e59d55d52bfd7696b775851f92107/testing/fixture.go#L146-L194
1020-
func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker) (runtime.Object, error) {
1066+
func dryPatch(action testing.PatchActionImpl, tracker testing.ObjectTracker, newObj runtime.Object) (runtime.Object, error) {
10211067
ns := action.GetNamespace()
10221068
gvr := action.GetResource()
10231069

10241070
obj, err := tracker.Get(gvr, ns, action.GetName())
10251071
if err != nil {
1072+
if action.GetPatchType() == types.ApplyPatchType {
1073+
return &unstructured.Unstructured{}, nil
1074+
}
10261075
return nil, err
10271076
}
10281077

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

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
It("is threadsafe", func() {
25202565
cl := NewClientBuilder().Build()
25212566

@@ -2681,6 +2726,7 @@ var _ = Describe("Fake client", func() {
26812726
expected.ResourceVersion = objActual.GetResourceVersion()
26822727
expected.Spec.Replicas = ptr.To(int32(3))
26832728
}
2729+
objExpected.SetManagedFields(objActual.GetManagedFields())
26842730
Expect(cmp.Diff(objExpected, objActual)).To(BeEmpty())
26852731

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