diff --git a/go.mod b/go.mod index fdcb3dc7..eb27e581 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,7 @@ require ( github.com/go-openapi/runtime v0.19.16 // indirect github.com/go-stack/stack v1.8.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.6.9 // indirect diff --git a/go.sum b/go.sum index eef281b4..02857469 100644 --- a/go.sum +++ b/go.sum @@ -140,6 +140,8 @@ github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWe github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= diff --git a/pkg/debug/server.go b/pkg/debug/server.go index 1d500baf..1b85cb36 100644 --- a/pkg/debug/server.go +++ b/pkg/debug/server.go @@ -1,7 +1,6 @@ package debug import ( - "encoding/json" "fmt" "io" "log/slog" @@ -19,6 +18,7 @@ import ( pkg "github.com/flant/shell-operator/pkg" utils "github.com/flant/shell-operator/pkg/utils/file" + json "github.com/flant/shell-operator/pkg/utils/json" structuredLogger "github.com/flant/shell-operator/pkg/utils/structured-logger" ) diff --git a/pkg/debug/server_test.go b/pkg/debug/server_test.go new file mode 100644 index 00000000..2c789634 --- /dev/null +++ b/pkg/debug/server_test.go @@ -0,0 +1,85 @@ +package debug + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newStubRequest(path string) *http.Request { + if path == "" { + path = "/debug/test" + } + return httptest.NewRequest(http.MethodGet, path, nil) +} + +func TestTransformUsingFormat_JSON(t *testing.T) { + var buf bytes.Buffer + err := transformUsingFormat(&buf, map[string]int{"count": 42}, "json") + require.NoError(t, err) + assert.Contains(t, buf.String(), `"count":42`) +} + +func TestTransformUsingFormat_YAML(t *testing.T) { + var buf bytes.Buffer + err := transformUsingFormat(&buf, map[string]string{"name": "test"}, "yaml") + require.NoError(t, err) + assert.Contains(t, buf.String(), "name: test") +} + +func TestTransformUsingFormat_Text_String(t *testing.T) { + var buf bytes.Buffer + err := transformUsingFormat(&buf, "hello world", "text") + require.NoError(t, err) + assert.Equal(t, "hello world", buf.String()) +} + +func TestTransformUsingFormat_Text_Bytes(t *testing.T) { + var buf bytes.Buffer + err := transformUsingFormat(&buf, []byte("raw bytes"), "text") + require.NoError(t, err) + assert.Equal(t, "raw bytes", buf.String()) +} + +func TestTransformUsingFormat_Text_Stringer(t *testing.T) { + var buf bytes.Buffer + err := transformUsingFormat(&buf, stringerVal{s: "from-stringer"}, "text") + require.NoError(t, err) + assert.Equal(t, "from-stringer", buf.String()) +} + +type stringerVal struct{ s string } + +func (sv stringerVal) String() string { return sv.s } + +func TestTransformUsingFormat_Text_NonStringFallsBackToJSON(t *testing.T) { + var buf bytes.Buffer + err := transformUsingFormat(&buf, map[string]int{"x": 1}, "text") + require.NoError(t, err) + assert.Contains(t, buf.String(), `"x":1`) +} + +func TestTransformUsingFormat_UnknownFormatFallsBackToJSON(t *testing.T) { + var buf bytes.Buffer + err := transformUsingFormat(&buf, map[string]int{"y": 2}, "xml") + require.NoError(t, err) + assert.Contains(t, buf.String(), `"y":2`) +} + +func TestFormatFromRequest_Default(t *testing.T) { + assert.Equal(t, "text", FormatFromRequest(newStubRequest(""))) +} + +func TestBadRequestError(t *testing.T) { + err := &BadRequestError{Msg: "missing param"} + assert.Equal(t, "missing param", err.Error()) +} + +func TestNotFoundError(t *testing.T) { + err := &NotFoundError{Msg: "not found"} + assert.Equal(t, "not found", err.Error()) +} diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index 68d6659f..6e723066 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "context" - "encoding/json" "fmt" "io" "log/slog" @@ -17,6 +16,7 @@ import ( "go.opentelemetry.io/otel" pkg "github.com/flant/shell-operator/pkg" + json "github.com/flant/shell-operator/pkg/utils/json" utils "github.com/flant/shell-operator/pkg/utils/labels" ) diff --git a/pkg/executor/executor_test.go b/pkg/executor/executor_test.go index e14edc49..ccfc52fb 100644 --- a/pkg/executor/executor_test.go +++ b/pkg/executor/executor_test.go @@ -3,7 +3,7 @@ package executor import ( "bytes" "context" - "encoding/json" + json "github.com/flant/shell-operator/pkg/utils/json" "fmt" "io" "math/rand/v2" diff --git a/pkg/filter/jq/apply.go b/pkg/filter/jq/apply.go index 8e402ceb..6f81fa37 100644 --- a/pkg/filter/jq/apply.go +++ b/pkg/filter/jq/apply.go @@ -1,13 +1,13 @@ package jq import ( - "encoding/json" "errors" "fmt" "github.com/itchyny/gojq" "github.com/flant/shell-operator/pkg/filter" + json "github.com/flant/shell-operator/pkg/utils/json" ) var ( diff --git a/pkg/filter/jq/apply_test.go b/pkg/filter/jq/apply_test.go index c0087e28..4a80f91a 100644 --- a/pkg/filter/jq/apply_test.go +++ b/pkg/filter/jq/apply_test.go @@ -1,7 +1,7 @@ package jq import ( - "encoding/json" + json "github.com/flant/shell-operator/pkg/utils/json" "testing" . "github.com/onsi/gomega" diff --git a/pkg/hook/binding_context/binding_context.go b/pkg/hook/binding_context/binding_context.go index a3514133..c139bc1f 100644 --- a/pkg/hook/binding_context/binding_context.go +++ b/pkg/hook/binding_context/binding_context.go @@ -1,7 +1,6 @@ package bindingcontext import ( - "encoding/json" "io" "github.com/deckhouse/deckhouse/pkg/log" @@ -10,6 +9,7 @@ import ( htypes "github.com/flant/shell-operator/pkg/hook/types" kemtypes "github.com/flant/shell-operator/pkg/kube_events_manager/types" + json "github.com/flant/shell-operator/pkg/utils/json" ) // BindingContext contains information about event for hook diff --git a/pkg/hook/binding_context/binding_context_test.go b/pkg/hook/binding_context/binding_context_test.go index 5517cf1f..12364172 100644 --- a/pkg/hook/binding_context/binding_context_test.go +++ b/pkg/hook/binding_context/binding_context_test.go @@ -2,9 +2,10 @@ package bindingcontext import ( "bytes" - "encoding/json" "testing" + json "github.com/flant/shell-operator/pkg/utils/json" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" diff --git a/pkg/hook/config/schemas.go b/pkg/hook/config/schemas.go index f6f785aa..491583a7 100644 --- a/pkg/hook/config/schemas.go +++ b/pkg/hook/config/schemas.go @@ -1,11 +1,12 @@ package config import ( - "encoding/json" "fmt" "github.com/go-openapi/spec" "github.com/go-openapi/swag" + + json "github.com/flant/shell-operator/pkg/utils/json" ) var Schemas = map[string]string{ diff --git a/pkg/hook/operation.go b/pkg/hook/operation.go index 1c158569..dea30c56 100644 --- a/pkg/hook/operation.go +++ b/pkg/hook/operation.go @@ -2,13 +2,14 @@ package hook import ( "bytes" - "encoding/json" "fmt" "io" "os" "strings" "github.com/hashicorp/go-multierror" + + json "github.com/flant/shell-operator/pkg/utils/json" ) type MetricOperation struct { diff --git a/pkg/kube/object_patch/helpers.go b/pkg/kube/object_patch/helpers.go index 21da061e..4e794959 100644 --- a/pkg/kube/object_patch/helpers.go +++ b/pkg/kube/object_patch/helpers.go @@ -2,7 +2,6 @@ package object_patch import ( "bytes" - "encoding/json" "fmt" "io" @@ -13,6 +12,7 @@ import ( "github.com/flant/kube-client/manifest" "github.com/flant/shell-operator/pkg/filter" + json "github.com/flant/shell-operator/pkg/utils/json" ) func unmarshalFromJSONOrYAML(specs []byte) ([]OperationSpec, error) { diff --git a/pkg/kube/object_patch/validation.go b/pkg/kube/object_patch/validation.go index 821fcf1c..f57fdfc9 100644 --- a/pkg/kube/object_patch/validation.go +++ b/pkg/kube/object_patch/validation.go @@ -1,7 +1,6 @@ package object_patch import ( - "encoding/json" "fmt" "github.com/go-openapi/spec" @@ -10,6 +9,8 @@ import ( "github.com/go-openapi/validate" "github.com/hashicorp/go-multierror" "sigs.k8s.io/yaml" + + json "github.com/flant/shell-operator/pkg/utils/json" ) var Schemas = map[string]string{ diff --git a/pkg/kube_events_manager/filter.go b/pkg/kube_events_manager/filter.go index aaf4338a..134b38ae 100644 --- a/pkg/kube_events_manager/filter.go +++ b/pkg/kube_events_manager/filter.go @@ -2,7 +2,6 @@ package kubeeventsmanager import ( "context" - "encoding/json" "fmt" "reflect" "runtime" @@ -13,6 +12,7 @@ import ( "github.com/flant/shell-operator/pkg/filter" kemtypes "github.com/flant/shell-operator/pkg/kube_events_manager/types" utils_checksum "github.com/flant/shell-operator/pkg/utils/checksum" + json "github.com/flant/shell-operator/pkg/utils/json" ) // applyFilter filters object json representation with a pre-compiled jq expression, diff --git a/pkg/kube_events_manager/filter_test.go b/pkg/kube_events_manager/filter_test.go index 90785838..28765d6f 100644 --- a/pkg/kube_events_manager/filter_test.go +++ b/pkg/kube_events_manager/filter_test.go @@ -1,7 +1,7 @@ package kubeeventsmanager import ( - "encoding/json" + json "github.com/flant/shell-operator/pkg/utils/json" "testing" "github.com/stretchr/testify/assert" @@ -15,7 +15,8 @@ func TestApplyFilter(t *testing.T) { t.Run("filter func with error", func(t *testing.T) { uns := &unstructured.Unstructured{Object: map[string]interface{}{"foo": "bar"}} _, err := applyFilter(nil, "", filterFuncWithError, uns) - assert.EqualError(t, err, "filterFn (github.com/flant/shell-operator/pkg/kube_events_manager.filterFuncWithError) contains an error: invalid character 'a' looking for beginning of value") + require.Error(t, err) + assert.Contains(t, err.Error(), "filterFn (github.com/flant/shell-operator/pkg/kube_events_manager.filterFuncWithError) contains an error:") }) t.Run("nil compiledFilter computes checksum over full object", func(t *testing.T) { diff --git a/pkg/kube_events_manager/types/types.go b/pkg/kube_events_manager/types/types.go index 9e21fee3..ad443730 100644 --- a/pkg/kube_events_manager/types/types.go +++ b/pkg/kube_events_manager/types/types.go @@ -1,7 +1,6 @@ package types import ( - "encoding/json" "fmt" "log/slog" "strings" @@ -11,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" pkg "github.com/flant/shell-operator/pkg" + json "github.com/flant/shell-operator/pkg/utils/json" ) type WatchEventType string diff --git a/pkg/kube_events_manager/types/types_test.go b/pkg/kube_events_manager/types/types_test.go index 50e89206..985814c2 100644 --- a/pkg/kube_events_manager/types/types_test.go +++ b/pkg/kube_events_manager/types/types_test.go @@ -1,7 +1,7 @@ package types import ( - "encoding/json" + json "github.com/flant/shell-operator/pkg/utils/json" "sort" "testing" diff --git a/pkg/task/dump/dump.go b/pkg/task/dump/dump.go index b0d2a5c4..2a5d9e89 100644 --- a/pkg/task/dump/dump.go +++ b/pkg/task/dump/dump.go @@ -2,13 +2,13 @@ package dump import ( "context" - "encoding/json" "fmt" "sort" "strings" "github.com/flant/shell-operator/pkg/task" "github.com/flant/shell-operator/pkg/task/queue" + json "github.com/flant/shell-operator/pkg/utils/json" ) // asQueueNames implements sort.Interface for array of queue names. diff --git a/pkg/task/dump/dump_test.go b/pkg/task/dump/dump_test.go index 92984cb0..a6abaad8 100644 --- a/pkg/task/dump/dump_test.go +++ b/pkg/task/dump/dump_test.go @@ -2,7 +2,7 @@ package dump import ( "context" - "encoding/json" + json "github.com/flant/shell-operator/pkg/utils/json" "fmt" "sort" "testing" diff --git a/pkg/utils/json/bench_compare_test.go b/pkg/utils/json/bench_compare_test.go new file mode 100644 index 00000000..cd1a0468 --- /dev/null +++ b/pkg/utils/json/bench_compare_test.go @@ -0,0 +1,427 @@ +package json + +import ( + "bytes" + stdjson "encoding/json" + "fmt" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// Data generators – mimic the real payloads used by shell-operator. +// --------------------------------------------------------------------------- + +func makeSmallMap() map[string]interface{} { + return map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "pod-abc", + "namespace": "default", + }, + } +} + +func makeMediumMap() map[string]interface{} { + containers := make([]interface{}, 3) + for i := range containers { + containers[i] = map[string]interface{}{ + "name": fmt.Sprintf("container-%d", i), + "image": "nginx:1.25", + "ports": []interface{}{ + map[string]interface{}{"containerPort": float64(8080 + i)}, + }, + "env": []interface{}{ + map[string]interface{}{"name": "ENV_VAR", "value": "val"}, + }, + } + } + return map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "name": "pod-abc", + "namespace": "kube-system", + "labels": map[string]interface{}{ + "app": "nginx", + "version": "v1", + "team": "platform", + }, + "annotations": map[string]interface{}{ + "kubectl.kubernetes.io/last-applied-configuration": strings.Repeat("x", 200), + }, + }, + "spec": map[string]interface{}{ + "containers": containers, + "restartPolicy": "Always", + "nodeName": "worker-01", + }, + "status": map[string]interface{}{ + "phase": "Running", + "conditions": []interface{}{ + map[string]interface{}{"type": "Ready", "status": "True"}, + map[string]interface{}{"type": "Initialized", "status": "True"}, + }, + }, + } +} + +func makeLargeBindingContext(objectCount int) []map[string]interface{} { + objects := make([]interface{}, objectCount) + for i := range objects { + objects[i] = map[string]interface{}{ + "object": map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": fmt.Sprintf("cm-%d", i), + "namespace": "default", + "labels": map[string]interface{}{ + "app": "test", + }, + }, + "data": map[string]interface{}{ + "key1": strings.Repeat("a", 50), + "key2": strings.Repeat("b", 50), + }, + }, + "filterResult": map[string]interface{}{ + "name": fmt.Sprintf("cm-%d", i), + }, + } + } + return []map[string]interface{}{ + { + "binding": "configmaps", + "type": "Synchronization", + "watchEvent": "Added", + "objects": objects, + }, + } +} + +type metricOperation struct { + Name string `json:"name"` + Value *float64 `json:"value,omitempty"` + Buckets []float64 `json:"buckets,omitempty"` + Labels map[string]string `json:"labels"` + Group string `json:"group,omitempty"` + Action string `json:"action,omitempty"` +} + +func makeMetricOps(count int) []metricOperation { + ops := make([]metricOperation, count) + v := float64(1.0) + for i := range ops { + ops[i] = metricOperation{ + Name: fmt.Sprintf("metric_%d", i), + Value: &v, + Labels: map[string]string{"hook": "test", "queue": "main"}, + Action: "set", + } + } + return ops +} + +type admissionReview struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Request admissionRequest `json:"request"` + Response admissionResponse `json:"response,omitempty"` +} + +type admissionRequest struct { + UID string `json:"uid"` + Kind map[string]string `json:"kind"` + Resource map[string]string `json:"resource"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Operation string `json:"operation"` + Object map[string]interface{} `json:"object"` +} + +type admissionResponse struct { + UID string `json:"uid"` + Allowed bool `json:"allowed"` +} + +func makeAdmissionReview() admissionReview { + return admissionReview{ + APIVersion: "admission.k8s.io/v1", + Kind: "AdmissionReview", + Request: admissionRequest{ + UID: "abc-123-def", + Kind: map[string]string{"group": "", "version": "v1", "kind": "Pod"}, + Resource: map[string]string{"group": "", "version": "v1", "resource": "pods"}, + Name: "my-pod", + Namespace: "default", + Operation: "CREATE", + Object: makeMediumMap(), + }, + } +} + +// --------------------------------------------------------------------------- +// Marshal benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkMarshal_SmallMap_GoJSON(b *testing.B) { + in := makeSmallMap() + b.ResetTimer() + for range b.N { + _, _ = Marshal(in) + } +} + +func BenchmarkMarshal_SmallMap_StdJSON(b *testing.B) { + in := makeSmallMap() + b.ResetTimer() + for range b.N { + _, _ = stdjson.Marshal(in) + } +} + +func BenchmarkMarshal_MediumMap_GoJSON(b *testing.B) { + in := makeMediumMap() + b.ResetTimer() + for range b.N { + _, _ = Marshal(in) + } +} + +func BenchmarkMarshal_MediumMap_StdJSON(b *testing.B) { + in := makeMediumMap() + b.ResetTimer() + for range b.N { + _, _ = stdjson.Marshal(in) + } +} + +func BenchmarkMarshal_LargeBindingCtx50_GoJSON(b *testing.B) { + in := makeLargeBindingContext(50) + b.ResetTimer() + for range b.N { + _, _ = Marshal(in) + } +} + +func BenchmarkMarshal_LargeBindingCtx50_StdJSON(b *testing.B) { + in := makeLargeBindingContext(50) + b.ResetTimer() + for range b.N { + _, _ = stdjson.Marshal(in) + } +} + +func BenchmarkMarshal_LargeBindingCtx500_GoJSON(b *testing.B) { + in := makeLargeBindingContext(500) + b.ResetTimer() + for range b.N { + _, _ = Marshal(in) + } +} + +func BenchmarkMarshal_LargeBindingCtx500_StdJSON(b *testing.B) { + in := makeLargeBindingContext(500) + b.ResetTimer() + for range b.N { + _, _ = stdjson.Marshal(in) + } +} + +func BenchmarkMarshal_Struct_GoJSON(b *testing.B) { + in := makeMetricOps(10) + b.ResetTimer() + for range b.N { + _, _ = Marshal(in) + } +} + +func BenchmarkMarshal_Struct_StdJSON(b *testing.B) { + in := makeMetricOps(10) + b.ResetTimer() + for range b.N { + _, _ = stdjson.Marshal(in) + } +} + +func BenchmarkMarshal_AdmissionReview_GoJSON(b *testing.B) { + in := makeAdmissionReview() + b.ResetTimer() + for range b.N { + _, _ = Marshal(in) + } +} + +func BenchmarkMarshal_AdmissionReview_StdJSON(b *testing.B) { + in := makeAdmissionReview() + b.ResetTimer() + for range b.N { + _, _ = stdjson.Marshal(in) + } +} + +// --------------------------------------------------------------------------- +// Unmarshal benchmarks +// --------------------------------------------------------------------------- + +func BenchmarkUnmarshal_SmallMap_GoJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeSmallMap()) + b.ResetTimer() + for range b.N { + var v map[string]interface{} + _ = Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_SmallMap_StdJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeSmallMap()) + b.ResetTimer() + for range b.N { + var v map[string]interface{} + _ = stdjson.Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_MediumMap_GoJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeMediumMap()) + b.ResetTimer() + for range b.N { + var v map[string]interface{} + _ = Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_MediumMap_StdJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeMediumMap()) + b.ResetTimer() + for range b.N { + var v map[string]interface{} + _ = stdjson.Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_LargeBindingCtx50_GoJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeLargeBindingContext(50)) + b.ResetTimer() + for range b.N { + var v []map[string]interface{} + _ = Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_LargeBindingCtx50_StdJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeLargeBindingContext(50)) + b.ResetTimer() + for range b.N { + var v []map[string]interface{} + _ = stdjson.Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_LargeBindingCtx500_GoJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeLargeBindingContext(500)) + b.ResetTimer() + for range b.N { + var v []map[string]interface{} + _ = Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_LargeBindingCtx500_StdJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeLargeBindingContext(500)) + b.ResetTimer() + for range b.N { + var v []map[string]interface{} + _ = stdjson.Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_Struct_GoJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeMetricOps(10)) + b.ResetTimer() + for range b.N { + var v []metricOperation + _ = Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_Struct_StdJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeMetricOps(10)) + b.ResetTimer() + for range b.N { + var v []metricOperation + _ = stdjson.Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_AdmissionReview_GoJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeAdmissionReview()) + b.ResetTimer() + for range b.N { + var v admissionReview + _ = Unmarshal(data, &v) + } +} + +func BenchmarkUnmarshal_AdmissionReview_StdJSON(b *testing.B) { + data, _ := stdjson.Marshal(makeAdmissionReview()) + b.ResetTimer() + for range b.N { + var v admissionReview + _ = stdjson.Unmarshal(data, &v) + } +} + +// --------------------------------------------------------------------------- +// Encoder benchmarks (streaming write, like BindingContextList.WriteJson) +// --------------------------------------------------------------------------- + +func BenchmarkEncoder_LargeBindingCtx500_GoJSON(b *testing.B) { + in := makeLargeBindingContext(500) + var buf bytes.Buffer + b.ResetTimer() + for range b.N { + buf.Reset() + enc := NewEncoder(&buf) + _ = enc.Encode(in) + } +} + +func BenchmarkEncoder_LargeBindingCtx500_StdJSON(b *testing.B) { + in := makeLargeBindingContext(500) + var buf bytes.Buffer + b.ResetTimer() + for range b.N { + buf.Reset() + enc := stdjson.NewEncoder(&buf) + _ = enc.Encode(in) + } +} + +// --------------------------------------------------------------------------- +// Decoder benchmarks (streaming read, like MetricOperationsFromReader) +// --------------------------------------------------------------------------- + +func BenchmarkDecoder_MetricOps_GoJSON(b *testing.B) { + ops := makeMetricOps(20) + data, _ := stdjson.Marshal(ops) + b.ResetTimer() + for range b.N { + dec := NewDecoder(bytes.NewReader(data)) + var v []metricOperation + _ = dec.Decode(&v) + } +} + +func BenchmarkDecoder_MetricOps_StdJSON(b *testing.B) { + ops := makeMetricOps(20) + data, _ := stdjson.Marshal(ops) + b.ResetTimer() + for range b.N { + dec := stdjson.NewDecoder(bytes.NewReader(data)) + var v []metricOperation + _ = dec.Decode(&v) + } +} diff --git a/pkg/utils/json/json.go b/pkg/utils/json/json.go new file mode 100644 index 00000000..6fdac28a --- /dev/null +++ b/pkg/utils/json/json.go @@ -0,0 +1,31 @@ +// Package json is a drop-in replacement for encoding/json that delegates to +// github.com/goccy/go-json for better marshal/unmarshal performance. +// All public symbols required by the rest of the codebase are re-exported here +// so that callers only need to change their import path. +package json + +import ( + gojson "github.com/goccy/go-json" +) + +type ( + Decoder = gojson.Decoder + Encoder = gojson.Encoder + Number = gojson.Number + RawMessage = gojson.RawMessage + Marshaler = gojson.Marshaler + Unmarshaler = gojson.Unmarshaler + InvalidUnmarshalError = gojson.InvalidUnmarshalError + UnmarshalTypeError = gojson.UnmarshalTypeError + SyntaxError = gojson.SyntaxError + MarshalerError = gojson.MarshalerError +) + +var ( + Marshal = gojson.Marshal + MarshalIndent = gojson.MarshalIndent + Unmarshal = gojson.Unmarshal + NewDecoder = gojson.NewDecoder + NewEncoder = gojson.NewEncoder + Valid = gojson.Valid +) diff --git a/pkg/utils/json/json_test.go b/pkg/utils/json/json_test.go new file mode 100644 index 00000000..ece10944 --- /dev/null +++ b/pkg/utils/json/json_test.go @@ -0,0 +1,272 @@ +package json + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMarshal_Struct(t *testing.T) { + type sample struct { + Name string `json:"name"` + Value int `json:"value"` + } + in := sample{Name: "test", Value: 42} + + got, err := Marshal(in) + require.NoError(t, err) + + want, err := json.Marshal(in) + require.NoError(t, err) + + assert.JSONEq(t, string(want), string(got)) +} + +func TestMarshal_Map(t *testing.T) { + in := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "pod-1", + "namespace": "default", + }, + "kind": "Pod", + "apiVersion": "v1", + } + + got, err := Marshal(in) + require.NoError(t, err) + + want, err := json.Marshal(in) + require.NoError(t, err) + + assert.JSONEq(t, string(want), string(got)) +} + +func TestMarshal_Nil(t *testing.T) { + got, err := Marshal(nil) + require.NoError(t, err) + assert.Equal(t, "null", string(got)) +} + +func TestMarshalIndent(t *testing.T) { + in := map[string]string{"a": "b"} + + got, err := MarshalIndent(in, "", " ") + require.NoError(t, err) + + want, err := json.MarshalIndent(in, "", " ") + require.NoError(t, err) + + assert.Equal(t, string(want), string(got)) +} + +func TestUnmarshal_Struct(t *testing.T) { + type sample struct { + Name string `json:"name"` + Value int `json:"value"` + } + + data := `{"name":"hello","value":99}` + + var got sample + require.NoError(t, Unmarshal([]byte(data), &got)) + assert.Equal(t, "hello", got.Name) + assert.Equal(t, 99, got.Value) +} + +func TestUnmarshal_Map(t *testing.T) { + data := `{"key":"val","nested":{"a":1}}` + + var got map[string]interface{} + require.NoError(t, Unmarshal([]byte(data), &got)) + assert.Equal(t, "val", got["key"]) + nested, ok := got["nested"].(map[string]interface{}) + require.True(t, ok) + assert.InDelta(t, float64(1), nested["a"], 0.001) +} + +func TestUnmarshal_InvalidJSON(t *testing.T) { + var v interface{} + err := Unmarshal([]byte("not-json"), &v) + assert.Error(t, err) +} + +func TestNewEncoder(t *testing.T) { + var buf bytes.Buffer + enc := NewEncoder(&buf) + + require.NoError(t, enc.Encode(map[string]int{"x": 1})) + + var got map[string]int + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, 1, got["x"]) +} + +func TestNewDecoder(t *testing.T) { + input := `{"name":"test","count":3}` + dec := NewDecoder(strings.NewReader(input)) + + type sample struct { + Name string `json:"name"` + Count int `json:"count"` + } + var got sample + require.NoError(t, dec.Decode(&got)) + assert.Equal(t, "test", got.Name) + assert.Equal(t, 3, got.Count) +} + +func TestNewDecoder_Stream(t *testing.T) { + input := `{"a":1} +{"a":2} +{"a":3} +` + dec := NewDecoder(strings.NewReader(input)) + + var results []int + for { + var v map[string]int + err := dec.Decode(&v) + if err == io.EOF { + break + } + require.NoError(t, err) + results = append(results, v["a"]) + } + assert.Equal(t, []int{1, 2, 3}, results) +} + +func TestValid(t *testing.T) { + assert.True(t, Valid([]byte(`{"key": "value"}`))) + assert.True(t, Valid([]byte(`[1,2,3]`))) + assert.True(t, Valid([]byte(`"hello"`))) + assert.False(t, Valid([]byte(`{invalid`))) + assert.False(t, Valid([]byte(``))) +} + +func TestNumber(t *testing.T) { + data := `{"n": 12345678901234567890}` + + dec := NewDecoder(strings.NewReader(data)) + dec.UseNumber() + + var got map[string]interface{} + require.NoError(t, dec.Decode(&got)) + + num, ok := got["n"].(Number) + require.True(t, ok) + assert.Equal(t, "12345678901234567890", num.String()) +} + +func TestMarshal_CustomMarshaler(t *testing.T) { + v := customType{Val: "hello"} + got, err := Marshal(v) + require.NoError(t, err) + assert.Equal(t, `"custom:hello"`, string(got)) +} + +type customType struct { + Val string +} + +func (c customType) MarshalJSON() ([]byte, error) { + return Marshal("custom:" + c.Val) +} + +func TestRoundTrip_Slice(t *testing.T) { + original := []map[string]interface{}{ + {"binding": "test", "type": "Event"}, + {"binding": "sync", "type": "Synchronization"}, + } + + data, err := Marshal(original) + require.NoError(t, err) + + var decoded []map[string]interface{} + require.NoError(t, Unmarshal(data, &decoded)) + + assert.Len(t, decoded, 2) + assert.Equal(t, "test", decoded[0]["binding"]) + assert.Equal(t, "Synchronization", decoded[1]["type"]) +} + +func TestRoundTrip_NestedUnstructured(t *testing.T) { + original := map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "my-config", + "namespace": "kube-system", + "labels": map[string]interface{}{ + "app": "test", + }, + }, + "data": map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + } + + data, err := Marshal(original) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, Unmarshal(data, &decoded)) + + assert.Equal(t, "v1", decoded["apiVersion"]) + meta, ok := decoded["metadata"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "my-config", meta["name"]) + labels, ok := meta["labels"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "test", labels["app"]) +} + +func TestEncoder_MultipleValues(t *testing.T) { + var buf bytes.Buffer + enc := NewEncoder(&buf) + + require.NoError(t, enc.Encode(map[string]int{"a": 1})) + require.NoError(t, enc.Encode(map[string]int{"b": 2})) + + dec := NewDecoder(&buf) + var v1, v2 map[string]int + require.NoError(t, dec.Decode(&v1)) + require.NoError(t, dec.Decode(&v2)) + assert.Equal(t, 1, v1["a"]) + assert.Equal(t, 2, v2["b"]) +} + +func BenchmarkMarshal_Map(b *testing.B) { + in := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "pod-1", + "namespace": "default", + "labels": map[string]interface{}{"app": "test", "version": "v1"}, + }, + "kind": "Pod", + "apiVersion": "v1", + "spec": map[string]interface{}{ + "containers": []interface{}{ + map[string]interface{}{"name": "main", "image": "nginx:latest"}, + }, + }, + } + b.ResetTimer() + for range b.N { + _, _ = Marshal(in) + } +} + +func BenchmarkUnmarshal_Map(b *testing.B) { + data := []byte(`{"metadata":{"name":"pod-1","namespace":"default","labels":{"app":"test","version":"v1"}},"kind":"Pod","apiVersion":"v1","spec":{"containers":[{"name":"main","image":"nginx:latest"}]}}`) + b.ResetTimer() + for range b.N { + var v map[string]interface{} + _ = Unmarshal(data, &v) + } +} diff --git a/pkg/webhook/admission/handler.go b/pkg/webhook/admission/handler.go index 61873bcc..d34f35e8 100644 --- a/pkg/webhook/admission/handler.go +++ b/pkg/webhook/admission/handler.go @@ -2,7 +2,6 @@ package admission import ( "context" - "encoding/json" "fmt" "log/slog" "net/http" @@ -15,6 +14,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" pkg "github.com/flant/shell-operator/pkg" + json "github.com/flant/shell-operator/pkg/utils/json" structuredLogger "github.com/flant/shell-operator/pkg/utils/structured-logger" ) diff --git a/pkg/webhook/admission/response.go b/pkg/webhook/admission/response.go index cf06e9c9..4e92edd3 100644 --- a/pkg/webhook/admission/response.go +++ b/pkg/webhook/admission/response.go @@ -2,12 +2,13 @@ package admission import ( "bytes" - "encoding/json" "fmt" "io" "os" "strconv" "strings" + + json "github.com/flant/shell-operator/pkg/utils/json" ) type Response struct { diff --git a/pkg/webhook/conversion/handler.go b/pkg/webhook/conversion/handler.go index be761971..ee8a0e99 100644 --- a/pkg/webhook/conversion/handler.go +++ b/pkg/webhook/conversion/handler.go @@ -2,7 +2,6 @@ package conversion import ( "context" - "encoding/json" "errors" "fmt" "log/slog" @@ -17,6 +16,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" pkg "github.com/flant/shell-operator/pkg" + json "github.com/flant/shell-operator/pkg/utils/json" structuredLogger "github.com/flant/shell-operator/pkg/utils/structured-logger" ) diff --git a/pkg/webhook/conversion/handler_test.go b/pkg/webhook/conversion/handler_test.go new file mode 100644 index 00000000..81bf5cdb --- /dev/null +++ b/pkg/webhook/conversion/handler_test.go @@ -0,0 +1,77 @@ +package conversion + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestExtractAPIVersions_Single(t *testing.T) { + objs := []runtime.RawExtension{ + {Raw: []byte(`{"apiVersion":"example.com/v1","kind":"Foo"}`)}, + } + + versions := ExtractAPIVersions(objs) + assert.Equal(t, []string{"example.com/v1"}, versions) +} + +func TestExtractAPIVersions_Multiple(t *testing.T) { + objs := []runtime.RawExtension{ + {Raw: []byte(`{"apiVersion":"example.com/v1","kind":"Foo"}`)}, + {Raw: []byte(`{"apiVersion":"example.com/v2","kind":"Foo"}`)}, + {Raw: []byte(`{"apiVersion":"example.com/v3","kind":"Foo"}`)}, + } + + versions := ExtractAPIVersions(objs) + assert.Len(t, versions, 3) + assert.Contains(t, versions, "example.com/v1") + assert.Contains(t, versions, "example.com/v2") + assert.Contains(t, versions, "example.com/v3") +} + +func TestExtractAPIVersions_Deduplicated(t *testing.T) { + objs := []runtime.RawExtension{ + {Raw: []byte(`{"apiVersion":"example.com/v1","kind":"Foo"}`)}, + {Raw: []byte(`{"apiVersion":"example.com/v1","kind":"Bar"}`)}, + {Raw: []byte(`{"apiVersion":"example.com/v2","kind":"Foo"}`)}, + } + + versions := ExtractAPIVersions(objs) + assert.Len(t, versions, 2) + assert.Contains(t, versions, "example.com/v1") + assert.Contains(t, versions, "example.com/v2") +} + +func TestExtractAPIVersions_Empty(t *testing.T) { + versions := ExtractAPIVersions(nil) + assert.Empty(t, versions) +} + +func TestExtractAPIVersions_InvalidJSON(t *testing.T) { + objs := []runtime.RawExtension{ + {Raw: []byte(`not json`)}, + } + + versions := ExtractAPIVersions(objs) + assert.Len(t, versions, 1) + assert.Contains(t, versions, "") +} + +func TestDetectCrdName(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/mycrds.example.com", "mycrds.example.com"}, + {"/", ""}, + {"", ""}, + {"/foo/bar", "foo/bar"}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + assert.Equal(t, tt.want, detectCrdName(tt.path)) + }) + } +} diff --git a/pkg/webhook/conversion/response.go b/pkg/webhook/conversion/response.go index ce0188f3..35d351c5 100644 --- a/pkg/webhook/conversion/response.go +++ b/pkg/webhook/conversion/response.go @@ -2,7 +2,6 @@ package conversion import ( "bytes" - "encoding/json" "fmt" "io" "os" @@ -10,6 +9,8 @@ import ( "strings" "k8s.io/apimachinery/pkg/runtime" + + json "github.com/flant/shell-operator/pkg/utils/json" ) /* diff --git a/pkg/webhook/conversion/response_test.go b/pkg/webhook/conversion/response_test.go new file mode 100644 index 00000000..7cdb2e1b --- /dev/null +++ b/pkg/webhook/conversion/response_test.go @@ -0,0 +1,75 @@ +package conversion + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestResponseFromBytes_Success(t *testing.T) { + data := []byte(`{"failedMessage":"","convertedObjects":[{"raw":"eyJhcGlWZXJzaW9uIjoiZXhhbXBsZS5jb20vdjEifQ=="}]}`) + + resp, err := ResponseFromBytes(data) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Empty(t, resp.FailedMessage) + assert.Len(t, resp.ConvertedObjects, 1) +} + +func TestResponseFromBytes_WithFailedMessage(t *testing.T) { + data := []byte(`{"failedMessage":"conversion not supported"}`) + + resp, err := ResponseFromBytes(data) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, "conversion not supported", resp.FailedMessage) +} + +func TestResponseFromBytes_EmptyInput(t *testing.T) { + // ResponseFromBytes does not have an empty guard (ResponseFromFile does), + // so decoding empty input returns an EOF error. + resp, err := ResponseFromBytes([]byte{}) + assert.Error(t, err) + assert.Nil(t, resp) +} + +func TestResponseFromBytes_InvalidJSON(t *testing.T) { + data := []byte(`{not valid json}`) + + resp, err := ResponseFromBytes(data) + assert.Error(t, err) + assert.Nil(t, resp) +} + +func TestResponseFromReader(t *testing.T) { + input := `{"failedMessage":"","convertedObjects":[]}` + resp, err := ResponseFromReader(strings.NewReader(input)) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Empty(t, resp.FailedMessage) + assert.Empty(t, resp.ConvertedObjects) +} + +func TestResponse_Dump_WithFailedMessage(t *testing.T) { + resp := &Response{FailedMessage: "something broke"} + dump := resp.Dump() + assert.Contains(t, dump, "failedMessage=something broke") + assert.Contains(t, dump, "conversion.Response(") +} + +func TestResponse_Dump_WithConvertedObjects(t *testing.T) { + resp := &Response{ + ConvertedObjects: make([]runtime.RawExtension, 3), + } + dump := resp.Dump() + assert.Contains(t, dump, "convertedObjects.len=3") +} + +func TestResponse_Dump_Empty(t *testing.T) { + resp := &Response{} + dump := resp.Dump() + assert.Equal(t, "conversion.Response()", dump) +}