From 5993b0a13f919f393bdf76075a73f4856ed79d1c Mon Sep 17 00:00:00 2001 From: Youssef Omar Bouden Date: Thu, 12 Nov 2026 12:04:38 +0100 Subject: [PATCH] Store whole numbers as int64 on the AsObject fast path A Struct only knows float64 numbers, but apimachinery's unstructured accessors read whole numbers as int64, so GetGeneration and the other int64 getters returned 0 for every object read from a request. Signed-off-by: Youssef Omar Bouden --- resource/resource.go | 34 +++++++++++++- resource/resource_test.go | 96 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 resource/resource_test.go diff --git a/resource/resource.go b/resource/resource.go index 94616cd..5713184 100644 --- a/resource/resource.go +++ b/resource/resource.go @@ -20,6 +20,8 @@ limitations under the License. package resource import ( + "math" + "github.com/go-json-experiment/json" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/structpb" @@ -108,7 +110,7 @@ func AsObject(s *structpb.Struct, o runtime.Object) error { // We try to avoid a JSON round-trip if o is backed by unstructured data. // Any type that is or embeds *unstructured.Unstructured has this method. if u, ok := o.(interface{ SetUnstructuredContent(_ map[string]any) }); ok { - u.SetUnstructuredContent(s.AsMap()) + u.SetUnstructuredContent(asUnstructuredContent(s.AsMap())) return nil } @@ -119,6 +121,36 @@ func AsObject(s *structpb.Struct, o runtime.Object) error { return errors.Wrapf(json.Unmarshal(b, o, json.RejectUnknownMembers(true)), "cannot unmarshal JSON from %T into %T", s, o) } +// asUnstructuredContent converts the whole numbers in the supplied map from +// float64, the only number type a Struct knows, to int64. That is what +// apimachinery produces when it decodes JSON into unstructured content, and +// what its accessors such as GetGeneration expect. +func asUnstructuredContent(m map[string]any) map[string]any { + for k, v := range m { + m[k] = asUnstructuredValue(v) + } + return m +} + +func asUnstructuredValue(v any) any { + switch v := v.(type) { + case map[string]any: + return asUnstructuredContent(v) + case []any: + for i := range v { + v[i] = asUnstructuredValue(v[i]) + } + return v + case float64: + if v == math.Trunc(v) && v >= math.MinInt64 && v <= math.MaxInt64 { + return int64(v) + } + return v + default: + return v + } +} + // AsStruct gets the supplied struct from the supplied Kubernetes object. func AsStruct(o runtime.Object) (*structpb.Struct, error) { // We try to avoid a JSON round-trip if o is backed by unstructured data. diff --git a/resource/resource_test.go b/resource/resource_test.go new file mode 100644 index 0000000..95a91b7 --- /dev/null +++ b/resource/resource_test.go @@ -0,0 +1,96 @@ +/* +Copyright 2026 The Crossplane 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 resource + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/crossplane/function-sdk-go/resource/composed" +) + +func TestAsObjectUnstructuredNumbers(t *testing.T) { + type want struct { + generation int64 + object map[string]any + } + + cases := map[string]struct { + reason string + s map[string]any + want want + }{ + "WholeNumbersBecomeInt64": { + reason: "Whole numbers must be stored as int64 so that apimachinery's accessors, such as GetGeneration, can read them.", + s: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "Example", + "metadata": map[string]any{"name": "a", "generation": 8}, + "spec": map[string]any{"replicas": 3, "weights": []any{1, 2.5}}, + }, + want: want{ + generation: 8, + object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "Example", + "metadata": map[string]any{"name": "a", "generation": int64(8)}, + "spec": map[string]any{"replicas": int64(3), "weights": []any{int64(1), 2.5}}, + }, + }, + }, + "FractionsStayFloat64": { + reason: "A number with a fractional part has no int64 form and must be left alone.", + s: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "Example", + "metadata": map[string]any{"name": "a"}, + "spec": map[string]any{"ratio": 0.5}, + }, + want: want{ + object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "Example", + "metadata": map[string]any{"name": "a"}, + "spec": map[string]any{"ratio": 0.5}, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + s, err := structpb.NewStruct(tc.s) + if err != nil { + t.Fatalf("structpb.NewStruct(...): %v", err) + } + + o := composed.New() + if err := AsObject(s, o); err != nil { + t.Fatalf("AsObject(...): %v", err) + } + + if diff := cmp.Diff(tc.want.generation, o.GetGeneration()); diff != "" { + t.Errorf("\n%s\nAsObject(...): -want generation, +got generation:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.object, o.UnstructuredContent()); diff != "" { + t.Errorf("\n%s\nAsObject(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +}