From ee613fa1c3064d2369318c69f5c3e06502388577 Mon Sep 17 00:00:00 2001 From: Pullagura Santosha Date: Tue, 21 Jul 2026 18:08:06 +0530 Subject: [PATCH 1/4] fake client support scale subresource get/update for unstructured objects --- go.mod | 2 +- pkg/client/fake/client.go | 48 +++++++++++++++ pkg/client/fake/client_test.go | 105 +++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 12a4ee2f19..dac7c00b9f 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( k8s.io/client-go v0.37.0-alpha.3 k8s.io/klog/v2 v2.140.0 k8s.io/utils v0.0.0-20260626114624-be93311217bd + sigs.k8s.io/randfill v1.0.0 sigs.k8s.io/structured-merge-diff/v6 v6.4.2 sigs.k8s.io/yaml v1.6.0 ) @@ -105,5 +106,4 @@ require ( k8s.io/streaming v0.37.0-alpha.3 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.36.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect ) diff --git a/pkg/client/fake/client.go b/pkg/client/fake/client.go index d4ec431331..587d8f9eb0 100644 --- a/pkg/client/fake/client.go +++ b/pkg/client/fake/client.go @@ -1524,8 +1524,48 @@ func getSingleOrZeroOptions[T any](opts []T) (opt T, err error) { return } +func isUnstructuredScaleResource(obj *unstructured.Unstructured) bool { + gvk := obj.GroupVersionKind() + switch gvk { + case appsv1.SchemeGroupVersion.WithKind("Deployment"), + appsv1.SchemeGroupVersion.WithKind("ReplicaSet"), + appsv1.SchemeGroupVersion.WithKind("StatefulSet"), + corev1.SchemeGroupVersion.WithKind("ReplicationController"): + return true + default: + return false + } +} + func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { switch obj := obj.(type) { + case *unstructured.Unstructured: + if !isUnstructuredScaleResource(obj) { + return nil, fmt.Errorf("unimplemented scale subresource for resource %T", obj) + } + var replicas int32 = 1 + if replicasVal, found, err := unstructured.NestedInt64(obj.Object, "spec", "replicas"); err != nil { + return nil, err + } else if found { + replicas = int32(replicasVal) + } + var statusReplicas int32 + if statusReplicasVal, found, err := unstructured.NestedInt64(obj.Object, "status", "replicas"); err != nil { + return nil, err + } else if found { + statusReplicas = int32(statusReplicasVal) + } + return &autoscalingv1.Scale{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + UID: obj.GetUID(), + ResourceVersion: obj.GetResourceVersion(), + CreationTimestamp: obj.GetCreationTimestamp(), + }, + Spec: autoscalingv1.ScaleSpec{Replicas: replicas}, + Status: autoscalingv1.ScaleStatus{Replicas: statusReplicas}, + }, nil case *appsv1.Deployment: var replicas int32 = 1 if obj.Spec.Replicas != nil { @@ -1630,6 +1670,14 @@ func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { func applyScale(obj client.Object, scale *autoscalingv1.Scale) error { switch obj := obj.(type) { + case *unstructured.Unstructured: + if !isUnstructuredScaleResource(obj) { + return fmt.Errorf("unimplemented scale subresource for resource %T", obj) + } + if err := unstructured.SetNestedField(obj.Object, int64(scale.Spec.Replicas), "spec", "replicas"); err != nil { + return err + } + return nil case *appsv1.Deployment: obj.Spec.Replicas = new(scale.Spec.Replicas) case *appsv1.ReplicaSet: diff --git a/pkg/client/fake/client_test.go b/pkg/client/fake/client_test.go index e695157ba6..7dbf8d0036 100644 --- a/pkg/client/fake/client_test.go +++ b/pkg/client/fake/client_test.go @@ -44,6 +44,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/watch" clientgoapplyconfigurations "k8s.io/client-go/applyconfigurations" @@ -54,6 +55,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/randfill" ) const ( @@ -2624,6 +2626,109 @@ var _ = Describe("Fake client", func() { Expect(cl.SubResource(subResourceScale).Get(ctx, obj, scale).Error()).To(Equal(expectedErr)) Expect(cl.SubResource(subResourceScale).Update(ctx, obj, client.WithSubResourceBody(scale)).Error()).To(Equal(expectedErr)) }) + It("supports scale subresources on unstructured objects with spec.replicas", func(ctx SpecContext) { + obj := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{ + "name": "foo", + "namespace": "default", + }, + "spec": map[string]any{ + "replicas": int64(1), + }, + "status": map[string]any{ + "replicas": int64(1), + }, + }} + cl := NewClientBuilder().WithScheme(runtime.NewScheme()).WithObjects(obj).Build() + + scale := &autoscalingv1.Scale{} + Expect(cl.SubResource(subResourceScale).Get(ctx, obj, scale)).To(Succeed()) + Expect(scale.Spec.Replicas).To(Equal(int32(1))) + Expect(scale.Status.Replicas).To(Equal(int32(1))) + + scale.Spec.Replicas = 3 + Expect(cl.SubResource(subResourceScale).Update(ctx, obj, client.WithSubResourceBody(scale))).To(Succeed()) + + updated := &unstructured.Unstructured{} + updated.SetAPIVersion("apps/v1") + updated.SetKind("Deployment") + updated.SetName("foo") + updated.SetNamespace("default") + Expect(cl.Get(ctx, client.ObjectKeyFromObject(updated), updated)).To(Succeed()) + replicas, found, err := unstructured.NestedInt64(updated.Object, "spec", "replicas") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(int32(replicas)).To(Equal(int32(3))) + }) + + It("structured and unstructured scale subresources behave consistently", func(ctx SpecContext) { + seed := time.Now().UnixMicro() + GinkgoWriter.Printf("seed: %d\n", seed) + fuzzer := randfill.NewWithSeed(seed).Funcs( + func(d *appsv1.Deployment, c randfill.Continue) { + var replicas, statusReplicas int32 + c.Fill(&replicas) + c.Fill(&statusReplicas) + d.TypeMeta = metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"} + d.ObjectMeta = metav1.ObjectMeta{Name: "scale-" + rand.String(8), Namespace: "default"} + d.Spec.Replicas = &replicas + d.Status.Replicas = statusReplicas + }, + func(scale *autoscalingv1.Scale, c randfill.Continue) { + c.Fill(&scale.Spec.Replicas) + }, + ) + + for range 100 { + dep := &appsv1.Deployment{} + fuzzer.Fill(dep) + + unstrMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(dep) + Expect(err).NotTo(HaveOccurred()) + unstr := &unstructured.Unstructured{Object: unstrMap} + unstr.SetAPIVersion("apps/v1") + unstr.SetKind("Deployment") + + structuredCl := NewClientBuilder().WithObjects(dep.DeepCopy()).Build() + unstructuredCl := NewClientBuilder().WithScheme(runtime.NewScheme()).WithObjects(unstr.DeepCopy()).Build() + + depKey := dep.DeepCopy() + unstrKey := &unstructured.Unstructured{} + unstrKey.SetAPIVersion("apps/v1") + unstrKey.SetKind("Deployment") + unstrKey.SetName(dep.Name) + unstrKey.SetNamespace(dep.Namespace) + + scaleTyped, scaleUnstr := &autoscalingv1.Scale{}, &autoscalingv1.Scale{} + Expect(structuredCl.SubResource(subResourceScale).Get(ctx, depKey, scaleTyped)).To(Succeed()) + Expect(unstructuredCl.SubResource(subResourceScale).Get(ctx, unstrKey, scaleUnstr)).To(Succeed()) + Expect(scaleTyped.Spec.Replicas).To(Equal(scaleUnstr.Spec.Replicas)) + Expect(scaleTyped.Status.Replicas).To(Equal(scaleUnstr.Status.Replicas)) + + updateScale := &autoscalingv1.Scale{} + fuzzer.Fill(updateScale) + Expect(structuredCl.SubResource(subResourceScale).Update(ctx, depKey, client.WithSubResourceBody(updateScale.DeepCopy()))).To(Succeed()) + Expect(unstructuredCl.SubResource(subResourceScale).Update(ctx, unstrKey, client.WithSubResourceBody(updateScale.DeepCopy()))).To(Succeed()) + + Expect(structuredCl.Get(ctx, client.ObjectKeyFromObject(dep), depKey)).To(Succeed()) + Expect(depKey.Spec.Replicas).NotTo(BeNil()) + Expect(*depKey.Spec.Replicas).To(Equal(updateScale.Spec.Replicas)) + + Expect(unstructuredCl.Get(ctx, client.ObjectKeyFromObject(dep), unstrKey)).To(Succeed()) + replicas, found, err := unstructured.NestedInt64(unstrKey.Object, "spec", "replicas") + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(int32(replicas)).To(Equal(updateScale.Spec.Replicas)) + + scaleTyped, scaleUnstr = &autoscalingv1.Scale{}, &autoscalingv1.Scale{} + Expect(structuredCl.SubResource(subResourceScale).Get(ctx, depKey, scaleTyped)).To(Succeed()) + Expect(unstructuredCl.SubResource(subResourceScale).Get(ctx, unstrKey, scaleUnstr)).To(Succeed()) + Expect(scaleTyped.Spec.Replicas).To(Equal(scaleUnstr.Spec.Replicas)) + Expect(scaleTyped.Spec.Replicas).To(Equal(updateScale.Spec.Replicas)) + } + }) It("disallows scale subresources on non-existing objects", func(ctx SpecContext) { obj := &appsv1.Deployment{ From 8d6841f5e9046714e430dcd544ae2ab47a5c0f2b Mon Sep 17 00:00:00 2001 From: Pullagura Santosha Date: Wed, 22 Jul 2026 19:53:17 +0530 Subject: [PATCH 2/4] fake client: fix unstructured scale error text and selector --- pkg/client/fake/client.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/pkg/client/fake/client.go b/pkg/client/fake/client.go index 587d8f9eb0..409710ac6d 100644 --- a/pkg/client/fake/client.go +++ b/pkg/client/fake/client.go @@ -1541,7 +1541,7 @@ func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { switch obj := obj.(type) { case *unstructured.Unstructured: if !isUnstructuredScaleResource(obj) { - return nil, fmt.Errorf("unimplemented scale subresource for resource %T", obj) + return nil, fmt.Errorf("scale subresource for resource %T is not implemented", obj) } var replicas int32 = 1 if replicasVal, found, err := unstructured.NestedInt64(obj.Object, "spec", "replicas"); err != nil { @@ -1555,6 +1555,20 @@ func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { } else if found { statusReplicas = int32(statusReplicasVal) } + var selector string + if selectorVal, found, err := unstructured.NestedFieldNoCopy(obj.Object, "spec", "selector"); err != nil { + return nil, err + } else if found && selectorVal != nil { + selectorMap, ok := selectorVal.(map[string]any) + if !ok { + return nil, fmt.Errorf(".spec.selector accessor error: %v is of the type %T, expected map[string]interface{}", selectorVal, selectorVal) + } + ls := &metav1.LabelSelector{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(selectorMap, ls); err != nil { + return nil, err + } + selector = ls.String() + } return &autoscalingv1.Scale{ ObjectMeta: metav1.ObjectMeta{ Namespace: obj.GetNamespace(), @@ -1563,8 +1577,11 @@ func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { ResourceVersion: obj.GetResourceVersion(), CreationTimestamp: obj.GetCreationTimestamp(), }, - Spec: autoscalingv1.ScaleSpec{Replicas: replicas}, - Status: autoscalingv1.ScaleStatus{Replicas: statusReplicas}, + Spec: autoscalingv1.ScaleSpec{Replicas: replicas}, + Status: autoscalingv1.ScaleStatus{ + Replicas: statusReplicas, + Selector: selector, + }, }, nil case *appsv1.Deployment: var replicas int32 = 1 @@ -1672,7 +1689,7 @@ func applyScale(obj client.Object, scale *autoscalingv1.Scale) error { switch obj := obj.(type) { case *unstructured.Unstructured: if !isUnstructuredScaleResource(obj) { - return fmt.Errorf("unimplemented scale subresource for resource %T", obj) + return fmt.Errorf("scale subresource for resource %T is not implemented", obj) } if err := unstructured.SetNestedField(obj.Object, int64(scale.Spec.Replicas), "spec", "replicas"); err != nil { return err From 358acb9340272e6bfe49652ffaf2a4915b9f025c Mon Sep 17 00:00:00 2001 From: Pullagura Santosha Date: Thu, 23 Jul 2026 20:02:02 +0530 Subject: [PATCH 3/4] Reuse typed scale logic for unstructured objects --- pkg/client/fake/client.go | 85 ++++++++++++++------------------------- 1 file changed, 30 insertions(+), 55 deletions(-) diff --git a/pkg/client/fake/client.go b/pkg/client/fake/client.go index 409710ac6d..5c1528c615 100644 --- a/pkg/client/fake/client.go +++ b/pkg/client/fake/client.go @@ -1524,65 +1524,26 @@ func getSingleOrZeroOptions[T any](opts []T) (opt T, err error) { return } -func isUnstructuredScaleResource(obj *unstructured.Unstructured) bool { - gvk := obj.GroupVersionKind() - switch gvk { - case appsv1.SchemeGroupVersion.WithKind("Deployment"), - appsv1.SchemeGroupVersion.WithKind("ReplicaSet"), - appsv1.SchemeGroupVersion.WithKind("StatefulSet"), - corev1.SchemeGroupVersion.WithKind("ReplicationController"): - return true - default: - return false - } -} - func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { switch obj := obj.(type) { case *unstructured.Unstructured: - if !isUnstructuredScaleResource(obj) { + var typed client.Object + switch obj.GroupVersionKind() { + case appsv1.SchemeGroupVersion.WithKind("Deployment"): + typed = &appsv1.Deployment{} + case appsv1.SchemeGroupVersion.WithKind("ReplicaSet"): + typed = &appsv1.ReplicaSet{} + case appsv1.SchemeGroupVersion.WithKind("StatefulSet"): + typed = &appsv1.StatefulSet{} + case corev1.SchemeGroupVersion.WithKind("ReplicationController"): + typed = &corev1.ReplicationController{} + default: return nil, fmt.Errorf("scale subresource for resource %T is not implemented", obj) } - var replicas int32 = 1 - if replicasVal, found, err := unstructured.NestedInt64(obj.Object, "spec", "replicas"); err != nil { + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, typed); err != nil { return nil, err - } else if found { - replicas = int32(replicasVal) - } - var statusReplicas int32 - if statusReplicasVal, found, err := unstructured.NestedInt64(obj.Object, "status", "replicas"); err != nil { - return nil, err - } else if found { - statusReplicas = int32(statusReplicasVal) - } - var selector string - if selectorVal, found, err := unstructured.NestedFieldNoCopy(obj.Object, "spec", "selector"); err != nil { - return nil, err - } else if found && selectorVal != nil { - selectorMap, ok := selectorVal.(map[string]any) - if !ok { - return nil, fmt.Errorf(".spec.selector accessor error: %v is of the type %T, expected map[string]interface{}", selectorVal, selectorVal) - } - ls := &metav1.LabelSelector{} - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(selectorMap, ls); err != nil { - return nil, err - } - selector = ls.String() } - return &autoscalingv1.Scale{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: obj.GetNamespace(), - Name: obj.GetName(), - UID: obj.GetUID(), - ResourceVersion: obj.GetResourceVersion(), - CreationTimestamp: obj.GetCreationTimestamp(), - }, - Spec: autoscalingv1.ScaleSpec{Replicas: replicas}, - Status: autoscalingv1.ScaleStatus{ - Replicas: statusReplicas, - Selector: selector, - }, - }, nil + return extractScale(typed) case *appsv1.Deployment: var replicas int32 = 1 if obj.Spec.Replicas != nil { @@ -1688,13 +1649,27 @@ func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { func applyScale(obj client.Object, scale *autoscalingv1.Scale) error { switch obj := obj.(type) { case *unstructured.Unstructured: - if !isUnstructuredScaleResource(obj) { + var typed client.Object + switch obj.GroupVersionKind() { + case appsv1.SchemeGroupVersion.WithKind("Deployment"): + typed = &appsv1.Deployment{} + case appsv1.SchemeGroupVersion.WithKind("ReplicaSet"): + typed = &appsv1.ReplicaSet{} + case appsv1.SchemeGroupVersion.WithKind("StatefulSet"): + typed = &appsv1.StatefulSet{} + case corev1.SchemeGroupVersion.WithKind("ReplicationController"): + typed = &corev1.ReplicationController{} + default: return fmt.Errorf("scale subresource for resource %T is not implemented", obj) } - if err := unstructured.SetNestedField(obj.Object, int64(scale.Spec.Replicas), "spec", "replicas"); err != nil { + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, typed); err != nil { return err } - return nil + if err := applyScale(typed, scale); err != nil { + return err + } + // applyScale only updates Spec.Replicas for known types; write that back. + return unstructured.SetNestedField(obj.Object, int64(scale.Spec.Replicas), "spec", "replicas") case *appsv1.Deployment: obj.Spec.Replicas = new(scale.Spec.Replicas) case *appsv1.ReplicaSet: From e663f792fa02386a956e129eb182a340f92f9285 Mon Sep 17 00:00:00 2001 From: Pullagura Santosha Date: Tue, 28 Jul 2026 22:34:17 +0530 Subject: [PATCH 4/4] Drop unused typed convert on scale update --- pkg/client/fake/client.go | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/pkg/client/fake/client.go b/pkg/client/fake/client.go index 5c1528c615..65ff22f4ca 100644 --- a/pkg/client/fake/client.go +++ b/pkg/client/fake/client.go @@ -1649,27 +1649,15 @@ func extractScale(obj client.Object) (*autoscalingv1.Scale, error) { func applyScale(obj client.Object, scale *autoscalingv1.Scale) error { switch obj := obj.(type) { case *unstructured.Unstructured: - var typed client.Object switch obj.GroupVersionKind() { - case appsv1.SchemeGroupVersion.WithKind("Deployment"): - typed = &appsv1.Deployment{} - case appsv1.SchemeGroupVersion.WithKind("ReplicaSet"): - typed = &appsv1.ReplicaSet{} - case appsv1.SchemeGroupVersion.WithKind("StatefulSet"): - typed = &appsv1.StatefulSet{} - case corev1.SchemeGroupVersion.WithKind("ReplicationController"): - typed = &corev1.ReplicationController{} + case appsv1.SchemeGroupVersion.WithKind("Deployment"), + appsv1.SchemeGroupVersion.WithKind("ReplicaSet"), + appsv1.SchemeGroupVersion.WithKind("StatefulSet"), + corev1.SchemeGroupVersion.WithKind("ReplicationController"): + return unstructured.SetNestedField(obj.Object, int64(scale.Spec.Replicas), "spec", "replicas") default: return fmt.Errorf("scale subresource for resource %T is not implemented", obj) } - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, typed); err != nil { - return err - } - if err := applyScale(typed, scale); err != nil { - return err - } - // applyScale only updates Spec.Replicas for known types; write that back. - return unstructured.SetNestedField(obj.Object, int64(scale.Spec.Replicas), "spec", "replicas") case *appsv1.Deployment: obj.Spec.Replicas = new(scale.Spec.Replicas) case *appsv1.ReplicaSet: