Skip to content

Commit

Permalink
:warn: Fakeclient: Add apply support
Browse files Browse the repository at this point in the history
This change is a POC for adding apply patch support to 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
  • Loading branch information
alvaroaleman committed Feb 23, 2025
1 parent d6b3440 commit b8202b9
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 9 deletions.
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ require (
sigs.k8s.io/yaml v1.4.0
)

require sigs.k8s.io/structured-merge-diff/v4 v4.4.2

require (
cel.dev/expr v0.19.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
Expand Down Expand Up @@ -93,5 +95,4 @@ require (
k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.1 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
)
75 changes: 67 additions & 8 deletions pkg/client/fake/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,17 @@ 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"

Check failure on line 57 in pkg/client/fake/client.go

View workflow job for this annotation

GitHub Actions / lint

ST1019: package "k8s.io/client-go/kubernetes/scheme" is being imported more than once (stylecheck)

Check failure on line 57 in pkg/client/fake/client.go

View workflow job for this annotation

GitHub Actions / lint

ST1019: package "k8s.io/client-go/kubernetes/scheme" is being imported more than once (stylecheck)
clientgoscheme "k8s.io/client-go/kubernetes/scheme"

Check failure on line 58 in pkg/client/fake/client.go

View workflow job for this annotation

GitHub Actions / lint

ST1019(related information): other import of "k8s.io/client-go/kubernetes/scheme" (stylecheck)

Check failure on line 58 in pkg/client/fake/client.go

View workflow job for this annotation

GitHub Actions / lint

ST1019(related information): other import of "k8s.io/client-go/kubernetes/scheme" (stylecheck)
"k8s.io/client-go/testing"
"k8s.io/utils/ptr"

Expand Down Expand Up @@ -119,6 +123,7 @@ type ClientBuilder struct {
withStatusSubresource []client.Object
objectTracker testing.ObjectTracker
interceptorFuncs *interceptor.Funcs
typeConverters []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 @@ -160,6 +165,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 @@ -216,6 +223,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.
//
// 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 @@ -236,11 +255,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(),
}
}
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 @@ -901,6 +938,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 @@ -915,7 +958,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 @@ -930,7 +976,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 @@ -989,12 +1035,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 @@ -1039,10 +1088,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")
unstructured.SetNestedField(obj.Object, map[string]any{"some": "data"}, "data")

Check failure on line 2525 in pkg/client/fake/client_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `unstructured.SetNestedField` is not checked (errcheck)

Check failure on line 2525 in pkg/client/fake/client_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `unstructured.SetNestedField` is not checked (errcheck)

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"}))

unstructured.SetNestedField(obj.Object, map[string]any{"other": "data"}, "data")

Check failure on line 2534 in pkg/client/fake/client_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `unstructured.SetNestedField` is not checked (errcheck)

Check failure on line 2534 in pkg/client/fake/client_test.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `unstructured.SetNestedField` is not checked (errcheck)
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"}))
// })

scalableObjs := []client.Object{
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Expand Down Expand Up @@ -2594,6 +2639,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
60 changes: 60 additions & 0 deletions pkg/client/fake/typeconverter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
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"
utilerror "k8s.io/apimachinery/pkg/util/errors"

Check failure on line 23 in pkg/client/fake/typeconverter.go

View workflow job for this annotation

GitHub Actions / lint

import "k8s.io/apimachinery/pkg/util/errors" imported as "utilerror" but must be "kerrors" according to config (importas)

Check failure on line 23 in pkg/client/fake/typeconverter.go

View workflow job for this annotation

GitHub Actions / lint

import "k8s.io/apimachinery/pkg/util/errors" imported as "utilerror" but must be "kerrors" according to config (importas)
"k8s.io/apimachinery/pkg/util/managedfields"
"sigs.k8s.io/structured-merge-diff/v4/typed"
)

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", utilerror.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", utilerror.NewAggregate(errs))
}

0 comments on commit b8202b9

Please sign in to comment.