Repository: kubernetes-sigs/controller-runtime

Issue:
FakeClient: use apiMachinery json when serializing/deserializing
Fake client's implementation of status update uses "encoding/json" to manipulate status of objects.

pkg/client/fake/client.go:
```go 
import (
  ....
  "encoding/json"
  ...
)
func toMapStringAny(obj runtime.Object) (map[string]any, error) {
	if unstructured, isUnstructured := obj.(*unstructured.Unstructured); isUnstructured {
		return unstructured.Object, nil
	}

	serialized, err := json.Marshal(obj)
	if err != nil {
		return nil, err
	}

	u := map[string]any{}
	return u, json.Unmarshal(serialized, &u)
}
```
standard JSON package behaves differently than kubernetes real client, which uses `UnmarshalCaseSensitivePreserveInts`. 

I'm struggling with this specifically because of the difference in numbers, as json package from apimachinery tries to convert them to int64 (if possible) and standard JSON treats all numbers as float64. 


Hints from issue comments:
@troy0820: I put a PR for this but added the notes of the difference from the docs.  This doesn't seem to break anything and should resemble the fix you are looking for. @ezeriver94: > I put a PR for this but added the notes of the difference from the docs. This doesn't seem to break anything and should resemble the fix you are looking for.

Yes, it is what I found, thanks! hope it gets merged soon@alvaroaleman: Could you leave a small concrete example of where this ends up being problematic? Given that the json usage is internal, I don't fully understand under what circumstances this ends up becoming visible?@ezeriver94: I believe my use case is very specific, but essentially I have a CRD for which a field is a map[string]interface{} with these tags
```go
	// +kubebuilder:pruning:PreserveUnknownFields
	// +kubebuilder:validation:Schemaless
```
If one of the values of this map is an integer, when I retrieve an instance of this CRD using regular k8s client, the type of the field will be int64. If I use fake client to store the same instance, the produced object will define the type of that field as float64.
This alone would not be an issue, but it happens that I use [structhash](https://github.com/cnf/structhash) to create a hash of this instance and store it as a label of a template CR in order to detect during a reconciliation if the template for the desired instance has been already created or if I need to create a new one.
This behaviour changes wether I'm using a real client to reconcile or fake client, as the latter always produces a different hash than the one existing on the real kubernetes cluster.

I apologize if the explanation is not clear enough, I think this weird setup I have is not very common, and issues derived from this behavior of FakeClient will probably not happen to many people, but anyway, this is a real difference on criteria between Fake/Real which IMO could be unified without risk.@alvaroaleman: ~And you use using `*unstructured.Unstructured` or how is it possible the type of the field changes? The part I don't understand is that the json marshaling is short-circuited when doing that IIRC~

Sorry, didn't properly read your message

Solution patch:
diff --git a/pkg/client/fake/client.go b/pkg/client/fake/client.go
index 99124e2f..7dbab74a 100644
--- a/pkg/client/fake/client.go
+++ b/pkg/client/fake/client.go
@@ -19,7 +19,6 @@ package fake
 import (
 	"bytes"
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"reflect"
@@ -58,6 +57,7 @@ import (
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/json"
 	utilrand "k8s.io/apimachinery/pkg/util/rand"
 	"k8s.io/apimachinery/pkg/util/sets"
 	"k8s.io/apimachinery/pkg/util/strategicpatch"



Test patch:
diff --git a/pkg/client/fake/client_test.go b/pkg/client/fake/client_test.go
index f6a493f5..4d794b14 100644
--- a/pkg/client/fake/client_test.go
+++ b/pkg/client/fake/client_test.go
@@ -1810,6 +1810,56 @@ var _ = Describe("Fake client", func() {
 		Expect(cmp.Diff(objOriginal, actual)).To(BeEmpty())
 	})
 
+	It("should Unmarshal the schemaless object with int64 to preserve ints", func() {
+		schemeBuilder := &scheme.Builder{GroupVersion: schema.GroupVersion{Group: "test", Version: "v1"}}
+		schemeBuilder.Register(&WithSchemalessSpec{})
+
+		scheme := runtime.NewScheme()
+		Expect(schemeBuilder.AddToScheme(scheme)).NotTo(HaveOccurred())
+
+		spec := Schemaless{
+			"key": int64(1),
+		}
+
+		obj := &WithSchemalessSpec{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "a-foo",
+			},
+			Spec: spec,
+		}
+		cl := NewClientBuilder().WithScheme(scheme).WithStatusSubresource(obj).WithObjects(obj).Build()
+
+		Expect(cl.Update(context.Background(), obj)).To(Succeed())
+		Expect(obj.Spec).To(BeEquivalentTo(spec))
+		Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(obj), obj)).To(Succeed())
+		Expect(obj.Spec).To(BeEquivalentTo(spec))
+	})
+
+	It("should Unmarshal the schemaless object with float64 to preserve ints", func() {
+		schemeBuilder := &scheme.Builder{GroupVersion: schema.GroupVersion{Group: "test", Version: "v1"}}
+		schemeBuilder.Register(&WithSchemalessSpec{})
+
+		scheme := runtime.NewScheme()
+		Expect(schemeBuilder.AddToScheme(scheme)).NotTo(HaveOccurred())
+
+		spec := Schemaless{
+			"key": 1.1,
+		}
+
+		obj := &WithSchemalessSpec{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: "a-foo",
+			},
+			Spec: spec,
+		}
+		cl := NewClientBuilder().WithScheme(scheme).WithStatusSubresource(obj).WithObjects(obj).Build()
+
+		Expect(cl.Update(context.Background(), obj)).To(Succeed())
+		Expect(obj.Spec).To(BeEquivalentTo(spec))
+		Expect(cl.Get(context.Background(), client.ObjectKeyFromObject(obj), obj)).To(Succeed())
+		Expect(obj.Spec).To(BeEquivalentTo(spec))
+	})
+
 	It("should not change the status of unstructured objects that are configured to have a status subresource on update", func() {
 		obj := &unstructured.Unstructured{}
 		obj.SetAPIVersion("foo/v1")
@@ -2695,6 +2745,52 @@ var _ = Describe("Fake client", func() {
 	}
 })
 
+type Schemaless map[string]interface{}
+
+type WithSchemalessSpec struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec Schemaless `json:"spec,omitempty"`
+}
+
+func (t *WithSchemalessSpec) DeepCopy() *WithSchemalessSpec {
+	w := &WithSchemalessSpec{
+		ObjectMeta: *t.ObjectMeta.DeepCopy(),
+	}
+	w.TypeMeta = metav1.TypeMeta{
+		APIVersion: t.APIVersion,
+		Kind:       t.Kind,
+	}
+	t.Spec.DeepCopyInto(&w.Spec)
+
+	return w
+}
+
+func (t *WithSchemalessSpec) DeepCopyObject() runtime.Object {
+	return t.DeepCopy()
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Schemaless) DeepCopyInto(out *Schemaless) {
+	if *in != nil {
+		*out = make(Schemaless, len(*in))
+		for key := range *in {
+			(*out)[key] = (*in)[key]
+		}
+	}
+}
+
+// DeepCopy copies the receiver, creating a new Schemaless.
+func (in *Schemaless) DeepCopy() *Schemaless {
+	if in == nil {
+		return nil
+	}
+	out := new(Schemaless)
+	in.DeepCopyInto(out)
+	return out
+}
+
 type WithPointerMetaList struct {
 	*metav1.ListMeta
 	*metav1.TypeMeta