Skip to content

⚠️ Fakeclient: Add apply support #2981

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ module sigs.k8s.io/controller-runtime

go 1.24.0

replace k8s.io/apimachinery => /Users/alvaro/git/go/src/k8s.io/kubernetes/staging/src/k8s.io/apimachinery

replace k8s.io/client-go => /Users/alvaro/git/go/src/k8s.io/kubernetes/staging/src/k8s.io/client-go

require (
github.com/blang/semver/v4 v4.0.0
github.com/evanphx/json-patch/v5 v5.9.11
Expand Down Expand Up @@ -32,6 +36,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 +102,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
)
177 changes: 167 additions & 10 deletions pkg/client/fake/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,18 @@ 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/json"
"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"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/testing"
"k8s.io/utils/ptr"

Expand Down Expand Up @@ -131,6 +135,7 @@ type ClientBuilder struct {
withStatusSubresource []client.Object
objectTracker testing.ObjectTracker
interceptorFuncs *interceptor.Funcs
typeConverters []managedfields.TypeConverter
Copy link

@jpbetz jpbetz Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can this simply be typeConverter managedfields.TypeConverter? When multiTypeConverter is needed, it should implement the managedfields.TypeConverter interface and so can be initialized and used here?


// 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 +177,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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enforce this in code with an assertion?

// tracker.
func (f *ClientBuilder) WithObjectTracker(ot testing.ObjectTracker) *ClientBuilder {
f.objectTracker = ot
return f
Expand Down Expand Up @@ -228,6 +235,18 @@ 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.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enforce this in code with an assertion?

//
// If unset, this defaults to:
// * clientgoapplyconfigurations.NewTypeConverter(clientgoscheme.Scheme),
// * managedfields.NewDeducedTypeConverter(),
func (f *ClientBuilder) WithTypeConverters(typeConverters ...managedfields.TypeConverter) *ClientBuilder {
f.typeConverters = append(f.typeConverters, typeConverters...)
return f
}

// Build builds and returns a new fake client.
func (f *ClientBuilder) Build() client.WithWatch {
if f.scheme == nil {
Expand All @@ -248,11 +267,29 @@ func (f *ClientBuilder) Build() client.WithWatch {
withStatusSubResource.Insert(gvk)
}

if f.objectTracker != nil && len(f.typeConverters) > 0 {
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 len(f.typeConverters) == 0 {
f.typeConverters = []managedfields.TypeConverter{
// Use corresponding scheme to ensure the converter error
// for types it can't handle.
clientgoapplyconfigurations.NewTypeConverter(clientgoscheme.Scheme),
managedfields.NewDeducedTypeConverter(), // TODO: Don't do this by default
}
}
f.objectTracker = testing.NewFieldManagedObjectTracker(
f.scheme,
serializer.NewCodecFactory(f.scheme).UniversalDecoder(),
multiTypeConverter{upstream: f.typeConverters},
)
}
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 @@ -372,6 +409,9 @@ func convertFromUnstructuredIfNecessary(s *runtime.Scheme, o runtime.Object) (ru
if err != nil {
return nil, fmt.Errorf("scheme recognizes %s but failed to produce an object for it: %w", gvk, err)
}
if _, isTypedUnstructured := typed.(runtime.Unstructured); isTypedUnstructured {
return o, nil
}

unstructuredSerialized, err := json.Marshal(u)
if err != nil {
Expand All @@ -381,6 +421,16 @@ func convertFromUnstructuredIfNecessary(s *runtime.Scheme, o runtime.Object) (ru
return nil, fmt.Errorf("failed to unmarshal the content of %T into %T: %w", u, typed, err)
}

// Clear out apiVersion
if metaType, isMetaType := typed.(interface {
// Yes, there is a metav1.Type interface but metav1.TypeMeta does not implement it
SetGroupVersionKind(gvk schema.GroupVersionKind)
}); isMetaType {
metaType.SetGroupVersionKind(schema.GroupVersionKind{})
println("setting")
}
println("returning converted")

return typed, nil
}

Expand All @@ -394,15 +444,36 @@ func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Ob
}

func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus, deleting bool, opts metav1.UpdateOptions) error {
obj, err := t.updateObject(gvr, obj, ns, isStatus, deleting, opts.DryRun)
gvk, err := apiutil.GVKForObject(obj, t.scheme)
if err != nil {
return err
}
obj, err = t.updateObject(gvr, obj, ns, isStatus, deleting, opts.DryRun)
if err != nil {
return err
}
if obj == nil {
return nil
}

return t.ObjectTracker.Update(gvr, obj, ns, opts)
//TODO: Need to convert back
// obj, err = convertFromUnstructuredIfNecessary(t.scheme, obj)
// if err != nil {
// return err
// }

// TODO this should not get cleared in updateObject
if u, unstructured := obj.(*unstructured.Unstructured); unstructured {
u.SetGroupVersionKind(gvk)
}

println("Kind", obj.GetObjectKind().GroupVersionKind().Kind)
fmt.Printf("%T\n", obj)
err = t.ObjectTracker.Update(gvr, obj, ns, opts)
if err != nil {
println("upstream tracker err", err.Error())
}
return err
}

func (t versionedTracker) Patch(gvr schema.GroupVersionResource, obj runtime.Object, ns string, opts ...metav1.PatchOptions) error {
Expand Down Expand Up @@ -521,6 +592,10 @@ func (t versionedTracker) updateObject(gvr schema.GroupVersionResource, obj runt
}

func (c *fakeClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if err := c.addToSchemeIfUnknownAndUnstructured(obj); err != nil {
return err
}

c.schemeLock.RLock()
defer c.schemeLock.RUnlock()
gvr, err := getGVRFromObject(obj, c.scheme)
Expand Down Expand Up @@ -557,6 +632,13 @@ func (c *fakeClient) Get(ctx context.Context, key client.ObjectKey, obj client.O
}

func (c *fakeClient) Watch(ctx context.Context, list client.ObjectList, opts ...client.ListOption) (watch.Interface, error) {
if err := c.addToSchemeIfUnknownAndUnstructured(list); err != nil {
return nil, err
}

c.schemeLock.RLock()
defer c.schemeLock.RUnlock()

gvk, err := apiutil.GVKForObject(list, c.scheme)
if err != nil {
return nil, err
Expand All @@ -572,6 +654,10 @@ func (c *fakeClient) Watch(ctx context.Context, list client.ObjectList, opts ...
}

func (c *fakeClient) List(ctx context.Context, obj client.ObjectList, opts ...client.ListOption) error {
if err := c.addToSchemeIfUnknownAndUnstructured(obj); err != nil {
return err
}

c.schemeLock.RLock()
defer c.schemeLock.RUnlock()
gvk, err := apiutil.GVKForObject(obj, c.scheme)
Expand Down Expand Up @@ -741,8 +827,13 @@ func (c *fakeClient) IsObjectNamespaced(obj runtime.Object) (bool, error) {
}

func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
if err := c.addToSchemeIfUnknownAndUnstructured(obj); err != nil {
return err
}

c.schemeLock.RLock()
defer c.schemeLock.RUnlock()

createOptions := &client.CreateOptions{}
createOptions.ApplyOptions(opts)

Expand Down Expand Up @@ -779,8 +870,13 @@ func (c *fakeClient) Create(ctx context.Context, obj client.Object, opts ...clie
}

func (c *fakeClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
if err := c.addToSchemeIfUnknownAndUnstructured(obj); err != nil {
return err
}

c.schemeLock.RLock()
defer c.schemeLock.RUnlock()

gvr, err := getGVRFromObject(obj, c.scheme)
if err != nil {
return err
Expand Down Expand Up @@ -826,8 +922,13 @@ func (c *fakeClient) Delete(ctx context.Context, obj client.Object, opts ...clie
}

func (c *fakeClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error {
if err := c.addToSchemeIfUnknownAndUnstructured(obj); err != nil {
return err
}

c.schemeLock.RLock()
defer c.schemeLock.RUnlock()

gvk, err := apiutil.GVKForObject(obj, c.scheme)
if err != nil {
return err
Expand Down Expand Up @@ -877,8 +978,13 @@ func (c *fakeClient) Update(ctx context.Context, obj client.Object, opts ...clie
}

func (c *fakeClient) update(obj client.Object, isStatus bool, opts ...client.UpdateOption) error {
if err := c.addToSchemeIfUnknownAndUnstructured(obj); err != nil {
return err
}

c.schemeLock.RLock()
defer c.schemeLock.RUnlock()

updateOptions := &client.UpdateOptions{}
updateOptions.ApplyOptions(opts)

Expand Down Expand Up @@ -907,8 +1013,13 @@ func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.
}

func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
if err := c.addToSchemeIfUnknownAndUnstructured(obj); err != nil {
return err
}

c.schemeLock.RLock()
defer c.schemeLock.RUnlock()

patchOptions := &client.PatchOptions{}
patchOptions.ApplyOptions(opts)

Expand All @@ -926,6 +1037,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 @@ -940,7 +1057,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 @@ -955,7 +1075,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 @@ -1014,12 +1134,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 @@ -1064,10 +1187,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 Expand Up @@ -1600,3 +1733,27 @@ func AddIndex(c client.Client, obj runtime.Object, field string, extractValue cl

return nil
}

func (f *fakeClient) addToSchemeIfUnknownAndUnstructured(obj runtime.Object) error {
f.schemeLock.Lock()
defer f.schemeLock.Unlock()

_, isUnstructured := obj.(*unstructured.Unstructured)
_, isUnstructuredList := obj.(*unstructured.UnstructuredList)
_, isPartial := obj.(*metav1.PartialObjectMetadata)
_, isPartialList := obj.(*metav1.PartialObjectMetadataList)
if !isUnstructured && !isUnstructuredList && !isPartial && !isPartialList {
return nil
}

gvk, err := apiutil.GVKForObject(obj, f.scheme)
if err != nil {
return err
}

if !f.scheme.Recognizes(gvk) {
f.scheme.AddKnownTypeWithName(gvk, obj)
}

return nil
}
Loading
Loading