Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
83 changes: 75 additions & 8 deletions pkg/client/fake/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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())
}
Expand Down
46 changes: 46 additions & 0 deletions pkg/client/fake/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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{}
Expand Down
63 changes: 63 additions & 0 deletions pkg/client/fake/typeconverter.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading