diff --git a/deployment/cre/jobs/types/job_spec.go b/deployment/cre/jobs/types/job_spec.go index ed03ba6a336..a15d7e4d052 100644 --- a/deployment/cre/jobs/types/job_spec.go +++ b/deployment/cre/jobs/types/job_spec.go @@ -1,6 +1,7 @@ package job_types import ( + "encoding/json" "errors" "fmt" "strings" @@ -13,14 +14,45 @@ import ( type JobSpecInput map[string]any func (j JobSpecInput) UnmarshalTo(target any) error { - bytes, err := yaml.Marshal(j) + sanitized := convertJSONNumbers(map[string]any(j)) + bytes, err := yaml.Marshal(sanitized) if err != nil { - return fmt.Errorf("failed to marshal job spec input to json: %w", err) + return fmt.Errorf("failed to marshal job spec input to yaml: %w", err) } - return yaml.Unmarshal(bytes, target) } +func convertJSONNumbers(m map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = convertValue(v) + } + return out +} + +func convertValue(v any) any { + switch val := v.(type) { + case json.Number: + if i, err := val.Int64(); err == nil { + return i + } + if f, err := val.Float64(); err == nil { + return f + } + return val.String() + case map[string]any: + return convertJSONNumbers(val) + case []any: + out := make([]any, len(val)) + for i, item := range val { + out[i] = convertValue(item) + } + return out + default: + return v + } +} + func (j JobSpecInput) UnmarshalFrom(source any) error { bytes, err := yaml.Marshal(source) if err != nil { diff --git a/deployment/cre/jobs/types/job_spec_test.go b/deployment/cre/jobs/types/job_spec_test.go index 1cacb68e02f..a570857c9eb 100644 --- a/deployment/cre/jobs/types/job_spec_test.go +++ b/deployment/cre/jobs/types/job_spec_test.go @@ -1,6 +1,7 @@ package job_types_test import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" @@ -120,3 +121,68 @@ func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) { assert.Contains(t, err.Error(), "cannot unmarshal !!str") }) } + +func TestUnmarshalTo_JSONNumberIntFields(t *testing.T) { + t.Parallel() + + type DON struct { + Name string `yaml:"name"` + F int `yaml:"f"` + Handlers []string `yaml:"handlers"` + } + + type GatewayInput struct { + GatewayRequestTimeoutSec int `yaml:"gatewayRequestTimeoutSec"` + DONs []DON `yaml:"dons"` + SomeString string `yaml:"someString"` + } + + // Simulate the exact values that arrive after the YAML->JSON->UseNumber() pipeline. + // In the real pipeline, YamlNodeToAny converts YAML integers to json.Number, + // then json.Marshal -> env var -> json.Decoder.UseNumber() preserves them as json.Number. + input := job_types.JobSpecInput{ + "gatewayRequestTimeoutSec": json.Number("70"), + "someString": "hello", + "dons": []any{ + map[string]any{ + "name": "some_don", + "f": json.Number("1"), + "handlers": []any{"http-capabilities", "web-api-capabilities"}, + }, + }, + } + + var target GatewayInput + err := input.UnmarshalTo(&target) + require.NoError(t, err, "UnmarshalTo should handle json.Number values in int fields") + + assert.Equal(t, 70, target.GatewayRequestTimeoutSec) + assert.Equal(t, "hello", target.SomeString) + require.Len(t, target.DONs, 1) + assert.Equal(t, "some_don", target.DONs[0].Name) + assert.Equal(t, 1, target.DONs[0].F) + assert.Equal(t, []string{"http-capabilities", "web-api-capabilities"}, target.DONs[0].Handlers) +} + +// TestUnmarshalTo_NativeIntFields verifies the happy path where map values +// are already native Go ints (e.g. when constructed in Go code rather than +// through the JSON pipeline). This should always pass. +func TestUnmarshalTo_NativeIntFields(t *testing.T) { + t.Parallel() + + type Target struct { + Count int `yaml:"count"` + Message string `yaml:"message"` + } + + input := job_types.JobSpecInput{ + "count": 42, + "message": "test", + } + + var target Target + err := input.UnmarshalTo(&target) + require.NoError(t, err) + assert.Equal(t, 42, target.Count) + assert.Equal(t, "test", target.Message) +}