Description
I don’t believe this is a known issue (at least, did not find mention of it) so I wanted to raise it.
This bug impacts uses of client.MergeFrom
but also helper function like CreateOrPatch
(which uses MergeFrom
).
Summary: If you have an object (K8s or CRD) that has int64 properties, the MergeFrom
call will very likely generate invalid patch data if the values considered cannot be exactly encoded in a float64.
The reason is that the jsonpatch
library used to generate the patch is unaware of the underlying type and therefore uses “standard” json marshaling/unmarshaling. So when unmarshaling numbers (int/float), they are stored as float64.
If the number cannot be exactly encoded into a float64, we start seeing incorrect behaviors
The current flow to generate a patch is
- [controller-runtime] generate JSON []byte representation of both objects - marshaling is correctly based on type
- [jsonpatch] unmarshal each JSON []byte to map[string]interface{} - unmarshaling can yield loss in precision on numbers / truncated int
- [jsonpatch] process the values and evaluate whether there is a change - decision is based on invalid values
- [jsonpatch] marshal patch JSON - marshaling will store value with precision loss
There are two kinds of impact:
- store the wrong value in an object (you end up storing the value with loss of precision)
- do no detect a change between the two objects and omit the change in the patch.
I don’t consider it a bug in jsonpatch
. The library is following the JSON standard
Looking at the issue, I think that fixing it would require to switch to another json patch generation implementation that is aware of the underlying data types but that’s probably a big change.
I’m including a simple test program to illustrate the issue - MergeFrom generates an empty patch despite the change (I’ve used a Job because it was an existing object with an int64 property).
package main
import (
v1 "k8s.io/api/batch/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"slices"
"testing"
)
func TestPatch(t *testing.T) {
var largeInt64 int64 = 9223372036854775807
var similarLargeInt64 int64 = 9223372036854775800
var differentEnoughInt64 int64 = 8223372036854775807
j := v1.Job{
ObjectMeta: metav1.ObjectMeta{
Namespace: "test",
Name: "test",
},
Spec: v1.JobSpec{
ActiveDeadlineSeconds: &largeInt64,
},
}
patch := client.MergeFrom(j.DeepCopy())
j.Spec.ActiveDeadlineSeconds = &differentEnoughInt64
data, err := patch.Data(&j)
if err != nil {
t.Fatalf("patch returned an error: %s", err)
}
if slices.Equal(data, []byte("{}")) {
t.Fatalf("patch returned an empty patch: %s", string(data))
}
j.Spec.ActiveDeadlineSeconds = &similarLargeInt64
data, err = patch.Data(&j)
if err != nil {
t.Fatalf("patch returned an error: %s", err)
}
if slices.Equal(data, []byte("{}")) {
// It will fail here because the two int64 values are too close and get truncated to the same value
t.Fatalf("patch returned an empty patch: %s", string(data))
}
}