Skip to content

Commit d569207

Browse files
committed
⚠️ Fakeclient: Fix status handling
This change fixes the status handling of the fake status client by * Adding a new `WithStatusSubresource` option and pre-poluating it with all in-tree resources that have a status subresource * Making its `Update` and `Patch` not change the status of any such resource * Making its status client `Update` and `Patch` only change the status for any such resources Since this was completely broken before in that both non-status and status Update/Patch would always update everything and the status resources get pre-populated, this is a breaking change. Any test that relied on the previous behavior would pass incorrectly though, as it didn't match what the Kubernetes API does.
1 parent 5db1738 commit d569207

File tree

2 files changed

+421
-29
lines changed

2 files changed

+421
-29
lines changed

pkg/client/fake/client.go

Lines changed: 207 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ limitations under the License.
1717
package fake
1818

1919
import (
20+
"bytes"
2021
"context"
2122
"encoding/json"
2223
"errors"
2324
"fmt"
2425
"reflect"
26+
"runtime/debug"
2527
"strconv"
2628
"strings"
2729
"sync"
@@ -35,6 +37,7 @@ import (
3537
"k8s.io/apimachinery/pkg/runtime"
3638
"k8s.io/apimachinery/pkg/runtime/schema"
3739
utilrand "k8s.io/apimachinery/pkg/util/rand"
40+
"k8s.io/apimachinery/pkg/util/sets"
3841
"k8s.io/apimachinery/pkg/util/validation/field"
3942
"k8s.io/apimachinery/pkg/watch"
4043
"k8s.io/client-go/kubernetes/scheme"
@@ -48,13 +51,15 @@ import (
4851

4952
type versionedTracker struct {
5053
testing.ObjectTracker
51-
scheme *runtime.Scheme
54+
scheme *runtime.Scheme
55+
withStatusSubresource sets.Set[schema.GroupVersionKind]
5256
}
5357

5458
type fakeClient struct {
55-
tracker versionedTracker
56-
scheme *runtime.Scheme
57-
restMapper meta.RESTMapper
59+
tracker versionedTracker
60+
scheme *runtime.Scheme
61+
restMapper meta.RESTMapper
62+
withStatusSubresource sets.Set[schema.GroupVersionKind]
5863

5964
// indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK.
6065
// The inner map maps from index name to IndexerFunc.
@@ -95,12 +100,13 @@ func NewClientBuilder() *ClientBuilder {
95100

96101
// ClientBuilder builds a fake client.
97102
type ClientBuilder struct {
98-
scheme *runtime.Scheme
99-
restMapper meta.RESTMapper
100-
initObject []client.Object
101-
initLists []client.ObjectList
102-
initRuntimeObjects []runtime.Object
103-
objectTracker testing.ObjectTracker
103+
scheme *runtime.Scheme
104+
restMapper meta.RESTMapper
105+
initObject []client.Object
106+
initLists []client.ObjectList
107+
initRuntimeObjects []runtime.Object
108+
withStatusSubresource []client.Object
109+
objectTracker testing.ObjectTracker
104110

105111
// indexes maps each GroupVersionKind (GVK) to the indexes registered for that GVK.
106112
// The inner map maps from index name to IndexerFunc.
@@ -185,6 +191,13 @@ func (f *ClientBuilder) WithIndex(obj runtime.Object, field string, extractValue
185191
return f
186192
}
187193

194+
// WithStatusSubresource configures the passed object with a status subresource, which means
195+
// calls to Update and Patch will not alters its status.
196+
func (f *ClientBuilder) WithStatusSubresource(o ...client.Object) *ClientBuilder {
197+
f.withStatusSubresource = append(f.withStatusSubresource, o...)
198+
return f
199+
}
200+
188201
// Build builds and returns a new fake client.
189202
func (f *ClientBuilder) Build() client.WithWatch {
190203
if f.scheme == nil {
@@ -196,10 +209,19 @@ func (f *ClientBuilder) Build() client.WithWatch {
196209

197210
var tracker versionedTracker
198211

212+
withStatusSubResource := sets.New(inTreeResourcesWithStatus()...)
213+
for _, o := range f.withStatusSubresource {
214+
gvk, err := apiutil.GVKForObject(o, f.scheme)
215+
if err != nil {
216+
panic(fmt.Errorf("failed to get gvk for object %T: %w", withStatusSubResource, err))
217+
}
218+
withStatusSubResource.Insert(gvk)
219+
}
220+
199221
if f.objectTracker == nil {
200-
tracker = versionedTracker{ObjectTracker: testing.NewObjectTracker(f.scheme, scheme.Codecs.UniversalDecoder()), scheme: f.scheme}
222+
tracker = versionedTracker{ObjectTracker: testing.NewObjectTracker(f.scheme, scheme.Codecs.UniversalDecoder()), scheme: f.scheme, withStatusSubresource: withStatusSubResource}
201223
} else {
202-
tracker = versionedTracker{ObjectTracker: f.objectTracker, scheme: f.scheme}
224+
tracker = versionedTracker{ObjectTracker: f.objectTracker, scheme: f.scheme, withStatusSubresource: withStatusSubResource}
203225
}
204226

205227
for _, obj := range f.initObject {
@@ -217,11 +239,13 @@ func (f *ClientBuilder) Build() client.WithWatch {
217239
panic(fmt.Errorf("failed to add runtime object %v to fake client: %w", obj, err))
218240
}
219241
}
242+
220243
return &fakeClient{
221-
tracker: tracker,
222-
scheme: f.scheme,
223-
restMapper: f.restMapper,
224-
indexes: f.indexes,
244+
tracker: tracker,
245+
scheme: f.scheme,
246+
restMapper: f.restMapper,
247+
indexes: f.indexes,
248+
withStatusSubresource: withStatusSubResource,
225249
}
226250
}
227251

@@ -318,6 +342,16 @@ func convertFromUnstructuredIfNecessary(s *runtime.Scheme, o runtime.Object) (ru
318342
}
319343

320344
func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Object, ns string) error {
345+
isStatus := false
346+
// We apply patches using a client-go reaction that ends up calling the trackers Update. As we can't change
347+
// that reaction, we use the callstack to figure out if this originated from the status client.
348+
if bytes.Contains(debug.Stack(), []byte("sigs.k8s.io/controller-runtime/pkg/client/fake.(*fakeSubResourceClient).Patch")) {
349+
isStatus = true
350+
}
351+
return t.update(gvr, obj, ns, isStatus)
352+
}
353+
354+
func (t versionedTracker) update(gvr schema.GroupVersionResource, obj runtime.Object, ns string, isStatus bool) error {
321355
accessor, err := meta.Accessor(obj)
322356
if err != nil {
323357
return fmt.Errorf("failed to get accessor for object: %w", err)
@@ -348,6 +382,20 @@ func (t versionedTracker) Update(gvr schema.GroupVersionResource, obj runtime.Ob
348382
return err
349383
}
350384

385+
if t.withStatusSubresource.Has(gvk) {
386+
if isStatus { // copy everything but status and metadata.ResourceVersion from original object
387+
if err := copyNonStatusFrom(oldObject, obj); err != nil {
388+
return fmt.Errorf("failed to copy non-status field for object with status subresouce: %w", err)
389+
}
390+
} else { // copy status from original object
391+
if err := copyStatusFrom(oldObject, obj); err != nil {
392+
return fmt.Errorf("failed to copy the status for object with status subresource: %w", err)
393+
}
394+
}
395+
} else if isStatus {
396+
return apierrors.NewNotFound(gvr.GroupResource(), accessor.GetName())
397+
}
398+
351399
oldAccessor, err := meta.Accessor(oldObject)
352400
if err != nil {
353401
return err
@@ -689,6 +737,10 @@ func (c *fakeClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ..
689737
}
690738

691739
func (c *fakeClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
740+
return c.update(obj, false, opts...)
741+
}
742+
743+
func (c *fakeClient) update(obj client.Object, isStatus bool, opts ...client.UpdateOption) error {
692744
updateOptions := &client.UpdateOptions{}
693745
updateOptions.ApplyOptions(opts)
694746

@@ -706,10 +758,14 @@ func (c *fakeClient) Update(ctx context.Context, obj client.Object, opts ...clie
706758
if err != nil {
707759
return err
708760
}
709-
return c.tracker.Update(gvr, obj, accessor.GetNamespace())
761+
return c.tracker.update(gvr, obj, accessor.GetNamespace(), isStatus)
710762
}
711763

712764
func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
765+
return c.patch(obj, patch, opts...)
766+
}
767+
768+
func (c *fakeClient) patch(obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
713769
patchOptions := &client.PatchOptions{}
714770
patchOptions.ApplyOptions(opts)
715771

@@ -732,6 +788,11 @@ func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.
732788
return err
733789
}
734790

791+
gvk, err := apiutil.GVKForObject(obj, c.scheme)
792+
if err != nil {
793+
return err
794+
}
795+
735796
reaction := testing.ObjectReaction(c.tracker)
736797
handled, o, err := reaction(testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), data))
737798
if err != nil {
@@ -740,11 +801,6 @@ func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.
740801
if !handled {
741802
panic("tracker could not handle patch method")
742803
}
743-
744-
gvk, err := apiutil.GVKForObject(obj, c.scheme)
745-
if err != nil {
746-
return err
747-
}
748804
ta, err := meta.TypeAccessor(o)
749805
if err != nil {
750806
return err
@@ -762,6 +818,97 @@ func (c *fakeClient) Patch(ctx context.Context, obj client.Object, patch client.
762818
return err
763819
}
764820

821+
func copyNonStatusFrom(old, new runtime.Object) error {
822+
newClientObject, ok := new.(client.Object)
823+
if !ok {
824+
return fmt.Errorf("%T is not a client.Object", new)
825+
}
826+
// The only thing other than status we have to retain
827+
rv := newClientObject.GetResourceVersion()
828+
829+
oldMapStringAny, err := toMapStringAny(old)
830+
if err != nil {
831+
return fmt.Errorf("failed to convert old to *unstructured.Unstructured: %w", err)
832+
}
833+
newMapStringAny, err := toMapStringAny(new)
834+
if err != nil {
835+
return fmt.Errorf("failed to convert new to *unststructured.Unstructured: %w", err)
836+
}
837+
838+
// delete everything other than status in case it has fields that were not present in
839+
// the old object
840+
for k := range newMapStringAny {
841+
if k != "status" {
842+
delete(newMapStringAny, k)
843+
}
844+
}
845+
// copy everything other than status from the old object
846+
for k := range oldMapStringAny {
847+
if k != "status" {
848+
newMapStringAny[k] = oldMapStringAny[k]
849+
}
850+
}
851+
852+
newClientObject.SetResourceVersion(rv)
853+
854+
if err := fromMapStringAny(newMapStringAny, new); err != nil {
855+
return fmt.Errorf("failed to convert back from map[string]any: %w", err)
856+
}
857+
return nil
858+
}
859+
860+
// copyStatusFrom copies the status from old into new
861+
func copyStatusFrom(old, new runtime.Object) error {
862+
oldMapStringAny, err := toMapStringAny(old)
863+
if err != nil {
864+
return fmt.Errorf("failed to convert old to *unstructured.Unstructured: %w", err)
865+
}
866+
newMapStringAny, err := toMapStringAny(new)
867+
if err != nil {
868+
return fmt.Errorf("failed to convert new to *unststructured.Unstructured: %w", err)
869+
}
870+
871+
newMapStringAny["status"] = oldMapStringAny["status"]
872+
873+
if err := fromMapStringAny(newMapStringAny, new); err != nil {
874+
return fmt.Errorf("failed to convert back from map[string]any: %w", err)
875+
}
876+
877+
return nil
878+
}
879+
880+
func toMapStringAny(obj runtime.Object) (map[string]any, error) {
881+
if unstructured, isUnstructured := obj.(*unstructured.Unstructured); isUnstructured {
882+
return unstructured.Object, nil
883+
}
884+
885+
serialized, err := json.Marshal(obj)
886+
if err != nil {
887+
return nil, err
888+
}
889+
890+
u := map[string]any{}
891+
return u, json.Unmarshal(serialized, &u)
892+
}
893+
894+
func fromMapStringAny(u map[string]any, target runtime.Object) error {
895+
if targetUnstructured, isUnstructured := target.(*unstructured.Unstructured); isUnstructured {
896+
targetUnstructured.Object = u
897+
return nil
898+
}
899+
900+
serialized, err := json.Marshal(u)
901+
if err != nil {
902+
return fmt.Errorf("failed to serialize: %w", err)
903+
}
904+
905+
if err := json.Unmarshal(serialized, &target); err != nil {
906+
return fmt.Errorf("failed to deserialize: %w", err)
907+
}
908+
909+
return nil
910+
}
911+
765912
func (c *fakeClient) Status() client.SubResourceWriter {
766913
return c.SubResource("status")
767914
}
@@ -809,22 +956,17 @@ func (sw *fakeSubResourceClient) Create(ctx context.Context, obj client.Object,
809956
}
810957

811958
func (sw *fakeSubResourceClient) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error {
812-
// TODO(droot): This results in full update of the obj (spec + subresources). Need
813-
// a way to update subresource only.
814959
updateOptions := client.SubResourceUpdateOptions{}
815960
updateOptions.ApplyOptions(opts)
816961

817962
body := obj
818963
if updateOptions.SubResourceBody != nil {
819964
body = updateOptions.SubResourceBody
820965
}
821-
return sw.client.Update(ctx, body, &updateOptions.UpdateOptions)
966+
return sw.client.update(body, true, &updateOptions.UpdateOptions)
822967
}
823968

824969
func (sw *fakeSubResourceClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error {
825-
// TODO(droot): This results in full update of the obj (spec + subresources). Need
826-
// a way to update subresource only.
827-
828970
patchOptions := client.SubResourcePatchOptions{}
829971
patchOptions.ApplyOptions(opts)
830972

@@ -833,7 +975,7 @@ func (sw *fakeSubResourceClient) Patch(ctx context.Context, obj client.Object, p
833975
body = patchOptions.SubResourceBody
834976
}
835977

836-
return sw.client.Patch(ctx, body, patch, &patchOptions.PatchOptions)
978+
return sw.client.patch(body, patch, &patchOptions.PatchOptions)
837979
}
838980

839981
func allowsUnconditionalUpdate(gvk schema.GroupVersionKind) bool {
@@ -933,6 +1075,42 @@ func allowsCreateOnUpdate(gvk schema.GroupVersionKind) bool {
9331075
return false
9341076
}
9351077

1078+
func inTreeResourcesWithStatus() []schema.GroupVersionKind {
1079+
return []schema.GroupVersionKind{
1080+
{Version: "v1", Kind: "Namespace"},
1081+
{Version: "v1", Kind: "Node"},
1082+
{Version: "v1", Kind: "PersistentVolumeClaim"},
1083+
{Version: "v1", Kind: "PersistentVolume"},
1084+
{Version: "v1", Kind: "Pod"},
1085+
{Version: "v1", Kind: "ReplicationController"},
1086+
{Version: "v1", Kind: "Service"},
1087+
1088+
{Group: "apps", Version: "v1", Kind: "Deployment"},
1089+
{Group: "apps", Version: "v1", Kind: "DaemonSet"},
1090+
{Group: "apps", Version: "v1", Kind: "ReplicaSet"},
1091+
{Group: "apps", Version: "v1", Kind: "StatefulSet"},
1092+
1093+
{Group: "autoscaling", Version: "v1", Kind: "HorizontalPodAutoscaler"},
1094+
1095+
{Group: "batch", Version: "v1", Kind: "CronJob"},
1096+
{Group: "batch", Version: "v1", Kind: "Job"},
1097+
1098+
{Group: "certificates.k8s.io", Version: "v1", Kind: "CertificateSigningRequest"},
1099+
1100+
{Group: "networking.k8s.io", Version: "v1", Kind: "Ingress"},
1101+
{Group: "networking.k8s.io", Version: "v1", Kind: "NetworkPolicy"},
1102+
1103+
{Group: "policy", Version: "v1", Kind: "PodDisruptionBudget"},
1104+
1105+
{Group: "storage.k8s.io", Version: "v1", Kind: "VolumeAttachment"},
1106+
1107+
{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"},
1108+
1109+
{Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta2", Kind: "FlowSchema"},
1110+
{Group: "flowcontrol.apiserver.k8s.io", Version: "v1beta2", Kind: "PriorityLevelConfiguration"},
1111+
}
1112+
}
1113+
9361114
// zero zeros the value of a pointer.
9371115
func zero(x interface{}) {
9381116
if x == nil {

0 commit comments

Comments
 (0)