From 24460babec0fda7260ee7f79a7bd66000e4cec08 Mon Sep 17 00:00:00 2001 From: JasonXuDeveloper Date: Mon, 15 Dec 2025 14:39:48 +1100 Subject: [PATCH 1/5] feat: add ModeConfig interface with backward compatibility Add ModeConfig interface to enable self-configuring execution modes and provide backward compatibility for legacy LoadProfile format. ## Changes ### New files: - api/types/mode_config.go: ModeConfig interface and helpers - api/types/weighted_random_config.go: WeightedRandomConfig implementation - api/types/timeseries_config.go: TimeSeriesConfig implementation - Split test files for better organization ### Modified files: - api/types/load_traffic.go: - Add ExecutionMode type - Update LoadProfileSpec with Mode and ModeConfig fields - Add UnmarshalYAML/UnmarshalJSON for backward compatibility - Fix typo: RequestList.Selector (was "seletor") - Update Validate() method ## Features **ModeConfig Interface:** - Self-configuring modes declare their own overridable fields - Automatic CLI override extraction via BuildOverridesFromCLI() - Mode-specific validation and client configuration - Clean separation between mode logic and CLI tools **Backward Compatibility:** - Legacy format (rate/total/requests fields) auto-migrates to weighted-random mode - Both YAML and JSON unmarshaling supported - Existing load profiles continue to work without changes ## Testing All tests pass including: - Polymorphic deserialization tests - Backward compatibility tests for legacy format - CLI override extraction tests - Mode-specific configuration tests --- api/types/load_traffic.go | 216 ++++++++++-- api/types/load_traffic_test.go | 213 +++--------- api/types/mode_config.go | 87 +++++ api/types/mode_config_test.go | 173 ++++++++++ api/types/timeseries_config.go | 97 ++++++ api/types/timeseries_config_test.go | 151 +++++++++ api/types/weighted_random_config.go | 96 ++++++ api/types/weighted_random_config_test.go | 400 +++++++++++++++++++++++ 8 files changed, 1236 insertions(+), 197 deletions(-) create mode 100644 api/types/mode_config.go create mode 100644 api/types/mode_config_test.go create mode 100644 api/types/timeseries_config.go create mode 100644 api/types/timeseries_config_test.go create mode 100644 api/types/weighted_random_config.go create mode 100644 api/types/weighted_random_config_test.go diff --git a/api/types/load_traffic.go b/api/types/load_traffic.go index d5d9a3b5..e84d892c 100644 --- a/api/types/load_traffic.go +++ b/api/types/load_traffic.go @@ -9,6 +9,7 @@ import ( "strings" apitypes "k8s.io/apimachinery/pkg/types" + "gopkg.in/yaml.v2" ) // ContentType represents the format of response. @@ -31,6 +32,27 @@ func (ct ContentType) Validate() error { } } +// ExecutionMode represents the execution strategy for generating requests. +type ExecutionMode string + +const ( + // ModeWeightedRandom generates requests randomly based on weighted distribution. + ModeWeightedRandom ExecutionMode = "weighted-random" + // ModeTimeSeries replays requests from time-bucketed audit logs. + ModeTimeSeries ExecutionMode = "time-series" +) + +// Validate returns error if ExecutionMode is not supported. +func (em ExecutionMode) Validate() error { + switch em { + case ModeWeightedRandom, ModeTimeSeries: + return nil + default: + return fmt.Errorf("unsupported execution mode: %s", em) + } +} + + // LoadProfile defines how to create load traffic from one host to kube-apiserver. type LoadProfile struct { // Version defines the version of this object. @@ -41,14 +63,8 @@ type LoadProfile struct { Spec LoadProfileSpec `json:"spec" yaml:"spec"` } -// LoadProfileSpec defines the load traffic for traget resource. +// LoadProfileSpec defines the load traffic for target resource. type LoadProfileSpec struct { - // Rate defines the maximum requests per second (zero is no limit). - Rate float64 `json:"rate" yaml:"rate"` - // Total defines the total number of requests. - Total int `json:"total" yaml:"total"` - // Duration defines the running time in seconds. - Duration int `json:"duration" yaml:"duration"` // Conns defines total number of long connections used for traffic. Conns int `json:"conns" yaml:"conns"` // Client defines total number of HTTP clients. @@ -61,9 +77,12 @@ type LoadProfileSpec struct { // retrying upon receiving "Retry-After" headers and 429 status-code // in the response (<= 0 means no retry). MaxRetries int `json:"maxRetries" yaml:"maxRetries"` - // Requests defines the different kinds of requests with weights. - // The executor should randomly pick by weight. - Requests []*WeightedRequest `json:"requests" yaml:"requests"` + + // Mode defines the execution strategy (weighted-random, time-series, etc.). + Mode ExecutionMode `json:"mode" yaml:"mode"` + // ModeConfig contains mode-specific configuration. + // This is automatically deserialized to the correct type based on Mode. + ModeConfig ModeConfig `json:"modeConfig" yaml:"modeConfig"` } // KubeGroupVersionResource identifies the resource URI. @@ -120,7 +139,7 @@ type RequestList struct { // Limit defines the page size. Limit int `json:"limit" yaml:"limit"` // Selector defines how to identify a set of objects. - Selector string `json:"seletor" yaml:"seletor"` + Selector string `json:"selector" yaml:"selector"` // FieldSelector defines how to identify a set of objects with field selector. FieldSelector string `json:"fieldSelector" yaml:"fieldSelector"` } @@ -201,34 +220,181 @@ func (lp LoadProfile) Validate() error { return lp.Spec.Validate() } -// Validate verifies fields of LoadProfileSpec. -func (spec LoadProfileSpec) Validate() error { - if spec.Conns <= 0 { - return fmt.Errorf("conns requires > 0: %v", spec.Conns) +// UnmarshalYAML implements custom YAML unmarshaling for LoadProfileSpec. +// It automatically deserializes ModeConfig to the correct concrete type based on Mode. +// It also provides backward compatibility for legacy format (without mode field). +func (spec *LoadProfileSpec) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Create a temporary struct that has all fields explicitly (no embedding) + type tempSpec struct { + Conns int `yaml:"conns"` + Client int `yaml:"client"` + ContentType ContentType `yaml:"contentType"` + DisableHTTP2 bool `yaml:"disableHTTP2"` + MaxRetries int `yaml:"maxRetries"` + Mode ExecutionMode `yaml:"mode"` + ModeConfig map[string]interface{} `yaml:"modeConfig"` + + // Legacy fields (for backward compatibility) + Rate float64 `yaml:"rate"` + Total int `yaml:"total"` + Duration int `yaml:"duration"` + Requests []*WeightedRequest `yaml:"requests"` + } + + temp := &tempSpec{} + if err := unmarshal(temp); err != nil { + return err } - if spec.Rate < 0 { - return fmt.Errorf("rate requires >= 0: %v", spec.Rate) + // Copy common fields + spec.Conns = temp.Conns + spec.Client = temp.Client + spec.ContentType = temp.ContentType + spec.DisableHTTP2 = temp.DisableHTTP2 + spec.MaxRetries = temp.MaxRetries + + // Check if this is legacy format (no mode specified but has requests) + if temp.Mode == "" && len(temp.Requests) > 0 { + // Auto-migrate legacy format to weighted-random mode + spec.Mode = ModeWeightedRandom + spec.ModeConfig = &WeightedRandomConfig{ + Rate: temp.Rate, + Total: temp.Total, + Duration: temp.Duration, + Requests: temp.Requests, + } + return nil } - if spec.Total <= 0 && spec.Duration <= 0 { - return fmt.Errorf("total requires > 0: %v or duration > 0s: %v", spec.Total, spec.Duration) + // New format: mode is specified + spec.Mode = temp.Mode + + // Now unmarshal ModeConfig based on Mode + if temp.ModeConfig != nil { + var config ModeConfig + switch temp.Mode { + case ModeWeightedRandom: + config = &WeightedRandomConfig{} + case ModeTimeSeries: + config = &TimeSeriesConfig{} + default: + return fmt.Errorf("unknown mode: %s", temp.Mode) + } + + // Convert map to YAML bytes and unmarshal into typed struct + data, err := yaml.Marshal(temp.ModeConfig) + if err != nil { + return fmt.Errorf("failed to marshal modeConfig: %w", err) + } + if err := yaml.Unmarshal(data, config); err != nil { + return fmt.Errorf("failed to unmarshal modeConfig for mode %s: %w", temp.Mode, err) + } + spec.ModeConfig = config + } + + return nil +} + +// UnmarshalJSON implements custom JSON unmarshaling for LoadProfileSpec. +// It automatically deserializes ModeConfig to the correct concrete type based on Mode. +// It also provides backward compatibility for legacy format (without mode field). +func (spec *LoadProfileSpec) UnmarshalJSON(data []byte) error { + // Create a temporary struct that has all fields explicitly (no embedding) + type tempSpec struct { + Conns int `json:"conns"` + Client int `json:"client"` + ContentType ContentType `json:"contentType"` + DisableHTTP2 bool `json:"disableHTTP2"` + MaxRetries int `json:"maxRetries"` + Mode ExecutionMode `json:"mode"` + ModeConfig map[string]interface{} `json:"modeConfig"` + + // Legacy fields (for backward compatibility) + Rate float64 `json:"rate"` + Total int `json:"total"` + Duration int `json:"duration"` + Requests []*WeightedRequest `json:"requests"` + } + + temp := &tempSpec{} + if err := json.Unmarshal(data, temp); err != nil { + return err + } + + // Copy common fields + spec.Conns = temp.Conns + spec.Client = temp.Client + spec.ContentType = temp.ContentType + spec.DisableHTTP2 = temp.DisableHTTP2 + spec.MaxRetries = temp.MaxRetries + + // Check if this is legacy format (no mode specified but has requests) + if temp.Mode == "" && len(temp.Requests) > 0 { + // Auto-migrate legacy format to weighted-random mode + spec.Mode = ModeWeightedRandom + spec.ModeConfig = &WeightedRandomConfig{ + Rate: temp.Rate, + Total: temp.Total, + Duration: temp.Duration, + Requests: temp.Requests, + } + return nil + } + + // New format: mode is specified + spec.Mode = temp.Mode + + // Now unmarshal ModeConfig based on Mode + if temp.ModeConfig != nil { + var config ModeConfig + switch temp.Mode { + case ModeWeightedRandom: + config = &WeightedRandomConfig{} + case ModeTimeSeries: + config = &TimeSeriesConfig{} + default: + return fmt.Errorf("unknown mode: %s", temp.Mode) + } + + // Convert map to JSON bytes and unmarshal into typed struct + data, err := json.Marshal(temp.ModeConfig) + if err != nil { + return fmt.Errorf("failed to marshal modeConfig: %w", err) + } + if err := json.Unmarshal(data, config); err != nil { + return fmt.Errorf("failed to unmarshal modeConfig for mode %s: %w", temp.Mode, err) + } + spec.ModeConfig = config + } + + return nil +} + + +// Validate verifies fields of LoadProfileSpec. +func (spec *LoadProfileSpec) Validate() error { + + // Validate common fields + if spec.Conns <= 0 { + return fmt.Errorf("conns requires > 0: %v", spec.Conns) } if spec.Client <= 0 { return fmt.Errorf("client requires > 0: %v", spec.Client) } - err := spec.ContentType.Validate() - if err != nil { + if err := spec.ContentType.Validate(); err != nil { return err } - for idx, req := range spec.Requests { - if err := req.Validate(); err != nil { - return fmt.Errorf("idx: %v request: %v", idx, err) - } + if err := spec.Mode.Validate(); err != nil { + return err } + + if spec.ModeConfig == nil { + return fmt.Errorf("modeConfig is required") + } + return nil } diff --git a/api/types/load_traffic_test.go b/api/types/load_traffic_test.go index ab64b255..66c40376 100644 --- a/api/types/load_traffic_test.go +++ b/api/types/load_traffic_test.go @@ -7,210 +7,79 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" ) -func TestLoadProfileUnmarshalFromYAML(t *testing.T) { - in := ` -version: 1 -description: test -spec: - rate: 100 - total: 10000 - conns: 2 - client: 1 - contentType: json - requests: - - staleGet: - group: core - version: v1 - resource: pods - namespace: default - name: x1 - shares: 100 - - quorumGet: - group: core - version: v1 - resource: configmaps - namespace: default - name: x2 - shares: 150 - - staleList: - group: core - version: v1 - resource: pods - namespace: default - seletor: app=x2 - fieldSelector: spec.nodeName=x - shares: 200 - - quorumList: - group: core - version: v1 - resource: configmaps - namespace: default - limit: 10000 - seletor: app=x3 - shares: 400 - - put: - group: core - version: v1 - resource: configmaps - namespace: kperf - name: kperf- - keySpaceSize: 1000 - valueSize: 1024 - shares: 1000 - - getPodLog: - namespace: default - name: hello - container: main - tailLines: 1000 - limitBytes: 1024 - shares: 10 - - watchList: - group: core - version: v1 - resource: pods - namespace: default - seletor: app=x2 - fieldSelector: spec.nodeName=x - shares: 250 -` - - target := LoadProfile{} - require.NoError(t, yaml.Unmarshal([]byte(in), &target)) - assert.Equal(t, 1, target.Version) - assert.Equal(t, "test", target.Description) - assert.Equal(t, float64(100), target.Spec.Rate) - assert.Equal(t, 10000, target.Spec.Total) - assert.Equal(t, 2, target.Spec.Conns) - assert.Len(t, target.Spec.Requests, 7) - - assert.Equal(t, 100, target.Spec.Requests[0].Shares) - assert.NotNil(t, target.Spec.Requests[0].StaleGet) - assert.Equal(t, "pods", target.Spec.Requests[0].StaleGet.Resource) - assert.Equal(t, "v1", target.Spec.Requests[0].StaleGet.Version) - assert.Equal(t, "core", target.Spec.Requests[0].StaleGet.Group) - assert.Equal(t, "default", target.Spec.Requests[0].StaleGet.Namespace) - assert.Equal(t, "x1", target.Spec.Requests[0].StaleGet.Name) - - assert.NotNil(t, target.Spec.Requests[1].QuorumGet) - assert.Equal(t, 150, target.Spec.Requests[1].Shares) - - assert.Equal(t, 200, target.Spec.Requests[2].Shares) - assert.NotNil(t, target.Spec.Requests[2].StaleList) - assert.Equal(t, "pods", target.Spec.Requests[2].StaleList.Resource) - assert.Equal(t, "v1", target.Spec.Requests[2].StaleList.Version) - assert.Equal(t, "core", target.Spec.Requests[2].StaleList.Group) - assert.Equal(t, "default", target.Spec.Requests[2].StaleList.Namespace) - assert.Equal(t, 0, target.Spec.Requests[2].StaleList.Limit) - assert.Equal(t, "app=x2", target.Spec.Requests[2].StaleList.Selector) - assert.Equal(t, "spec.nodeName=x", target.Spec.Requests[2].StaleList.FieldSelector) - - assert.NotNil(t, target.Spec.Requests[3].QuorumList) - assert.Equal(t, 400, target.Spec.Requests[3].Shares) - - assert.Equal(t, 1000, target.Spec.Requests[4].Shares) - assert.NotNil(t, target.Spec.Requests[4].Put) - assert.Equal(t, "configmaps", target.Spec.Requests[4].Put.Resource) - assert.Equal(t, "v1", target.Spec.Requests[4].Put.Version) - assert.Equal(t, "core", target.Spec.Requests[4].Put.Group) - assert.Equal(t, "kperf", target.Spec.Requests[4].Put.Namespace) - assert.Equal(t, "kperf-", target.Spec.Requests[4].Put.Name) - assert.Equal(t, 1000, target.Spec.Requests[4].Put.KeySpaceSize) - assert.Equal(t, 1024, target.Spec.Requests[4].Put.ValueSize) - - assert.Equal(t, 10, target.Spec.Requests[5].Shares) - assert.NotNil(t, target.Spec.Requests[5].GetPodLog) - assert.Equal(t, "default", target.Spec.Requests[5].GetPodLog.Namespace) - assert.Equal(t, "hello", target.Spec.Requests[5].GetPodLog.Name) - assert.Equal(t, "main", target.Spec.Requests[5].GetPodLog.Container) - assert.Equal(t, int64(1000), *target.Spec.Requests[5].GetPodLog.TailLines) - assert.Equal(t, int64(1024), *target.Spec.Requests[5].GetPodLog.LimitBytes) - - assert.Equal(t, 250, target.Spec.Requests[6].Shares) - assert.NotNil(t, target.Spec.Requests[6].WatchList) - - assert.NoError(t, target.Validate()) -} - func TestWeightedRequest(t *testing.T) { - for _, r := range []struct { - name string - req *WeightedRequest - hasErr bool + tests := map[string]struct { + req WeightedRequest + err bool }{ - { - name: "shares < 0", - req: &WeightedRequest{Shares: -1}, - hasErr: true, + "shares < 0": { + req: WeightedRequest{ + Shares: -1, + }, + err: true, }, - { - name: "no request setting", - req: &WeightedRequest{Shares: 10}, - hasErr: true, + "no request setting": { + req: WeightedRequest{ + Shares: 100, + }, + err: true, }, - { - name: "empty version", - req: &WeightedRequest{ - Shares: 10, - StaleGet: &RequestGet{ + "empty version": { + req: WeightedRequest{ + Shares: 100, + StaleList: &RequestList{ KubeGroupVersionResource: KubeGroupVersionResource{ Resource: "pods", }, }, }, - hasErr: true, + err: true, }, - { - name: "empty resource", - req: &WeightedRequest{ - Shares: 10, - StaleGet: &RequestGet{ + "empty resource": { + req: WeightedRequest{ + Shares: 100, + StaleList: &RequestList{ KubeGroupVersionResource: KubeGroupVersionResource{ - Group: "core", Version: "v1", }, }, }, - hasErr: true, + err: true, }, - { - name: "wrong limit", - req: &WeightedRequest{ - Shares: 10, + "wrong limit": { + req: WeightedRequest{ + Shares: 100, StaleList: &RequestList{ KubeGroupVersionResource: KubeGroupVersionResource{ - Group: "core", - Version: "v1", Resource: "pods", + Version: "v1", }, Limit: -1, }, }, - hasErr: true, + err: true, }, - { - name: "no error", - req: &WeightedRequest{ - Shares: 10, - StaleGet: &RequestGet{ + "no error": { + req: WeightedRequest{ + Shares: 100, + StaleList: &RequestList{ KubeGroupVersionResource: KubeGroupVersionResource{ - Group: "core", - Version: "v1", Resource: "pods", + Version: "v1", }, - Namespace: "default", - Name: "testing", + Limit: 0, }, }, + err: false, }, - } { - r := r - t.Run(r.name, func(t *testing.T) { - err := r.req.Validate() - if r.hasErr { + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := tc.req.Validate() + if tc.err { assert.Error(t, err) } else { assert.NoError(t, err) diff --git a/api/types/mode_config.go b/api/types/mode_config.go new file mode 100644 index 00000000..f84d59ce --- /dev/null +++ b/api/types/mode_config.go @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package types + +// ModeConfig is a discriminated union for mode-specific configuration. +// It automatically deserializes to the correct concrete type based on the Mode field. +type ModeConfig interface { + isModeConfig() + // ApplyOverrides applies string key-value overrides to the config. + // This allows each mode to define its own override logic without + // coupling CLI/util code to specific config fields. + // Returns error if override key is invalid for this mode. + ApplyOverrides(overrides map[string]interface{}) error + // GetOverridableFields returns metadata about fields that can be overridden via CLI. + // This allows CLI tools to automatically extract and apply overrides without + // hardcoding field names and types. + GetOverridableFields() []OverridableField + // Validate performs mode-specific validation and normalization. + // This includes checking for conflicting settings and applying defaults. + // defaultOverrides provides default values from CLI (e.g., default total). + Validate(defaultOverrides map[string]interface{}) error + // ConfigureClientOptions returns mode-specific client configuration. + // This allows each mode to customize REST client behavior (e.g., QPS limiting). + ConfigureClientOptions() ClientOptions +} + +// ClientOptions contains mode-specific REST client configuration +type ClientOptions struct { + // QPS is the queries per second limit (0 means no limit) + QPS float64 +} + +// OverridableField describes a config field that can be overridden via CLI flags. +type OverridableField struct { + // Name is the field name (e.g., "rate", "total", "interval") + Name string + // Type describes the field type for CLI parsing + Type FieldType + // Description is help text for CLI flags + Description string +} + +// FieldType indicates the type of a field for CLI flag parsing +type FieldType string + +const ( + FieldTypeFloat64 FieldType = "float64" + FieldTypeInt FieldType = "int" + FieldTypeString FieldType = "string" + FieldTypeBool FieldType = "bool" +) + +// CLIContext is an interface for CLI flag access (wraps urfave/cli.Context) +// This allows the types package to extract overrides without depending on urfave/cli +type CLIContext interface { + IsSet(name string) bool + Float64(name string) float64 + Int(name string) int + String(name string) string + Bool(name string) bool +} + +// BuildOverridesFromCLI automatically builds an override map from CLI flags +// based on the mode config's declared overridable fields. +func BuildOverridesFromCLI(config ModeConfig, cliCtx CLIContext) map[string]interface{} { + overrides := make(map[string]interface{}) + + for _, field := range config.GetOverridableFields() { + if !cliCtx.IsSet(field.Name) { + continue + } + + switch field.Type { + case FieldTypeFloat64: + overrides[field.Name] = cliCtx.Float64(field.Name) + case FieldTypeInt: + overrides[field.Name] = cliCtx.Int(field.Name) + case FieldTypeString: + overrides[field.Name] = cliCtx.String(field.Name) + case FieldTypeBool: + overrides[field.Name] = cliCtx.Bool(field.Name) + } + } + + return overrides +} diff --git a/api/types/mode_config_test.go b/api/types/mode_config_test.go new file mode 100644 index 00000000..4fbd33fe --- /dev/null +++ b/api/types/mode_config_test.go @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestBuildOverridesFromCLI(t *testing.T) { + tests := map[string]struct { + config ModeConfig + cliValues map[string]interface{} + expectedResult map[string]interface{} + }{ + "weighted-random with rate and total": { + config: &WeightedRandomConfig{}, + cliValues: map[string]interface{}{ + "rate": float64(250), + "total": 5000, + }, + expectedResult: map[string]interface{}{ + "rate": float64(250), + "total": 5000, + }, + }, + "weighted-random with duration only": { + config: &WeightedRandomConfig{}, + cliValues: map[string]interface{}{ + "duration": 120, + }, + expectedResult: map[string]interface{}{ + "duration": 120, + }, + }, + "time-series with interval": { + config: &TimeSeriesConfig{}, + cliValues: map[string]interface{}{ + "interval": "500ms", + }, + expectedResult: map[string]interface{}{ + "interval": "500ms", + }, + }, + "no overrides set": { + config: &WeightedRandomConfig{}, + cliValues: map[string]interface{}{}, + expectedResult: map[string]interface{}{}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mockCLI := &mockCLIContext{values: tc.cliValues} + result := BuildOverridesFromCLI(tc.config, mockCLI) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func TestPolymorphicDeserialization(t *testing.T) { + tests := map[string]struct { + yaml string + expectedMode ExecutionMode + validateFunc func(*testing.T, ModeConfig) + }{ + "weighted-random mode": { + yaml: ` +version: 1 +spec: + mode: weighted-random + conns: 10 + client: 5 + contentType: json + modeConfig: + rate: 150 + total: 2000 + requests: + - shares: 100 + staleGet: + version: v1 + resource: pods + namespace: default + name: test-pod +`, + expectedMode: ModeWeightedRandom, + validateFunc: func(t *testing.T, mc ModeConfig) { + wrConfig, ok := mc.(*WeightedRandomConfig) + require.True(t, ok) + assert.Equal(t, float64(150), wrConfig.Rate) + assert.Equal(t, 2000, wrConfig.Total) + assert.Len(t, wrConfig.Requests, 1) + }, + }, + "time-series mode": { + yaml: ` +version: 1 +spec: + mode: time-series + conns: 10 + client: 5 + contentType: json + modeConfig: + interval: "2s" + buckets: + - startTime: 0.0 + requests: + - method: GET + version: v1 + resource: pods + namespace: default +`, + expectedMode: ModeTimeSeries, + validateFunc: func(t *testing.T, mc ModeConfig) { + tsConfig, ok := mc.(*TimeSeriesConfig) + require.True(t, ok) + assert.Equal(t, "2s", tsConfig.Interval) + assert.Len(t, tsConfig.Buckets, 1) + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var profile LoadProfile + err := yaml.Unmarshal([]byte(tc.yaml), &profile) + require.NoError(t, err) + assert.Equal(t, tc.expectedMode, profile.Spec.Mode) + tc.validateFunc(t, profile.Spec.ModeConfig) + }) + } +} + +type mockCLIContext struct { + values map[string]interface{} +} + +func (m *mockCLIContext) IsSet(name string) bool { + _, exists := m.values[name] + return exists +} + +func (m *mockCLIContext) Float64(name string) float64 { + if v, ok := m.values[name].(float64); ok { + return v + } + return 0 +} + +func (m *mockCLIContext) Int(name string) int { + if v, ok := m.values[name].(int); ok { + return v + } + return 0 +} + +func (m *mockCLIContext) String(name string) string { + if v, ok := m.values[name].(string); ok { + return v + } + return "" +} + +func (m *mockCLIContext) Bool(name string) bool { + if v, ok := m.values[name].(bool); ok { + return v + } + return false +} diff --git a/api/types/timeseries_config.go b/api/types/timeseries_config.go new file mode 100644 index 00000000..5c356fc0 --- /dev/null +++ b/api/types/timeseries_config.go @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package types + +import "fmt" + +// TimeSeriesConfig defines configuration for time-series execution mode. +type TimeSeriesConfig struct { + // Interval defines the time bucket size (e.g., "1s", "60s"). + Interval string `json:"interval" yaml:"interval" mapstructure:"interval"` + // Buckets contains the time-bucketed requests. + Buckets []RequestBucket `json:"buckets" yaml:"buckets" mapstructure:"buckets"` +} + +// RequestBucket represents requests for one time slot. +type RequestBucket struct { + // StartTime is the relative time in seconds from benchmark start. + StartTime float64 `json:"startTime" yaml:"startTime" mapstructure:"startTime"` + // Requests are the exact requests to execute in this bucket. + Requests []ExactRequest `json:"requests" yaml:"requests" mapstructure:"requests"` +} + +// ExactRequest represents a single exact API request. +type ExactRequest struct { + // Method is the HTTP method (GET, POST, PUT, PATCH, DELETE, LIST). + Method string `json:"method" yaml:"method" mapstructure:"method"` + // Group is the API group. + Group string `json:"group,omitempty" yaml:"group,omitempty" mapstructure:"group"` + // Version is the API version. + Version string `json:"version" yaml:"version" mapstructure:"version"` + // Resource is the resource type. + Resource string `json:"resource" yaml:"resource" mapstructure:"resource"` + // Namespace is the object's namespace. + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty" mapstructure:"namespace"` + // Name is the object's name. + Name string `json:"name,omitempty" yaml:"name,omitempty" mapstructure:"name"` + // Body is the request body for POST/PUT/PATCH. + Body string `json:"body,omitempty" yaml:"body,omitempty" mapstructure:"body"` + // PatchType is the patch type for PATCH requests. + PatchType string `json:"patchType,omitempty" yaml:"patchType,omitempty" mapstructure:"patchType"` + // LabelSelector for LIST requests. + LabelSelector string `json:"labelSelector,omitempty" yaml:"labelSelector,omitempty" mapstructure:"labelSelector"` + // FieldSelector for LIST requests. + FieldSelector string `json:"fieldSelector,omitempty" yaml:"fieldSelector,omitempty" mapstructure:"fieldSelector"` + // Limit for LIST requests. + Limit int `json:"limit,omitempty" yaml:"limit,omitempty" mapstructure:"limit"` + // ResourceVersion for consistency. + ResourceVersion string `json:"resourceVersion,omitempty" yaml:"resourceVersion,omitempty" mapstructure:"resourceVersion"` +} + +// Ensure TimeSeriesConfig implements ModeConfig +func (*TimeSeriesConfig) isModeConfig() {} + +// GetOverridableFields implements ModeConfig for TimeSeriesConfig +func (c *TimeSeriesConfig) GetOverridableFields() []OverridableField { + return []OverridableField{ + { + Name: "interval", + Type: FieldTypeString, + Description: "Time bucket interval (e.g., '1s', '100ms')", + }, + } +} + +// ApplyOverrides implements ModeConfig for TimeSeriesConfig +func (c *TimeSeriesConfig) ApplyOverrides(overrides map[string]interface{}) error { + for key, value := range overrides { + switch key { + case "interval": + if v, ok := value.(string); ok { + c.Interval = v + } else { + return fmt.Errorf("interval must be string, got %T", value) + } + default: + return fmt.Errorf("unknown override key for time-series mode: %s", key) + } + } + return nil +} + +// Validate implements ModeConfig for TimeSeriesConfig +func (c *TimeSeriesConfig) Validate(defaultOverrides map[string]interface{}) error { + // Time-series mode doesn't have conflicting settings or defaults + // Could add validation for interval format, bucket ordering, etc. + return nil +} + +// ConfigureClientOptions implements ModeConfig for TimeSeriesConfig +func (c *TimeSeriesConfig) ConfigureClientOptions() ClientOptions { + // Time-series mode doesn't use client-side rate limiting + // (rate is controlled by bucket timing) + return ClientOptions{ + QPS: 0, // No limit + } +} diff --git a/api/types/timeseries_config_test.go b/api/types/timeseries_config_test.go new file mode 100644 index 00000000..36e89449 --- /dev/null +++ b/api/types/timeseries_config_test.go @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestTimeSeriesConfigGetOverridableFields(t *testing.T) { + config := &TimeSeriesConfig{} + fields := config.GetOverridableFields() + + assert.Len(t, fields, 1) + assert.Equal(t, "interval", fields[0].Name) + assert.Equal(t, FieldTypeString, fields[0].Type) + assert.Contains(t, fields[0].Description, "Time bucket") +} + +func TestTimeSeriesConfigApplyOverrides(t *testing.T) { + tests := map[string]struct { + initial TimeSeriesConfig + overrides map[string]interface{} + expected TimeSeriesConfig + err bool + }{ + "interval override": { + initial: TimeSeriesConfig{Interval: "1s"}, + overrides: map[string]interface{}{ + "interval": "100ms", + }, + expected: TimeSeriesConfig{Interval: "100ms"}, + err: false, + }, + "invalid interval type": { + initial: TimeSeriesConfig{Interval: "1s"}, + overrides: map[string]interface{}{ + "interval": 123, + }, + expected: TimeSeriesConfig{Interval: "1s"}, + err: true, + }, + "unknown key": { + initial: TimeSeriesConfig{Interval: "1s"}, + overrides: map[string]interface{}{ + "unknown": "value", + }, + expected: TimeSeriesConfig{Interval: "1s"}, + err: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + config := tc.initial + err := config.ApplyOverrides(tc.overrides) + if tc.err { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected.Interval, config.Interval) + } + }) + } +} + +func TestTimeSeriesConfigValidate(t *testing.T) { + config := &TimeSeriesConfig{Interval: "1s"} + err := config.Validate(nil) + assert.NoError(t, err) +} + +func TestTimeSeriesConfigConfigureClientOptions(t *testing.T) { + config := &TimeSeriesConfig{} + opts := config.ConfigureClientOptions() + assert.Equal(t, float64(0), opts.QPS, "time-series should not use client-side rate limiting") +} + +func TestLoadProfileTimeSeriesUnmarshalFromYAML(t *testing.T) { + in := ` +version: 1 +description: time-series test +spec: + conns: 5 + client: 10 + contentType: json + mode: time-series + modeConfig: + interval: "1s" + buckets: + - startTime: 0.0 + requests: + - method: GET + version: v1 + resource: pods + namespace: default + name: pod-1 + - method: LIST + version: v1 + resource: configmaps + namespace: kube-system + limit: 100 + - startTime: 1.0 + requests: + - method: POST + version: v1 + resource: configmaps + namespace: default + name: cm-1 + body: '{"data": {"key": "value"}}' +` + + target := LoadProfile{} + require.NoError(t, yaml.Unmarshal([]byte(in), &target)) + assert.Equal(t, 1, target.Version) + assert.Equal(t, "time-series test", target.Description) + assert.Equal(t, 5, target.Spec.Conns) + assert.Equal(t, ModeTimeSeries, target.Spec.Mode) + + tsConfig, ok := target.Spec.ModeConfig.(*TimeSeriesConfig) + require.True(t, ok, "ModeConfig should be *TimeSeriesConfig") + require.NotNil(t, tsConfig) + + assert.Equal(t, "1s", tsConfig.Interval) + assert.Len(t, tsConfig.Buckets, 2) + + assert.Equal(t, 0.0, tsConfig.Buckets[0].StartTime) + assert.Len(t, tsConfig.Buckets[0].Requests, 2) + assert.Equal(t, "GET", tsConfig.Buckets[0].Requests[0].Method) + assert.Equal(t, "pods", tsConfig.Buckets[0].Requests[0].Resource) + assert.Equal(t, "default", tsConfig.Buckets[0].Requests[0].Namespace) + assert.Equal(t, "pod-1", tsConfig.Buckets[0].Requests[0].Name) + + assert.Equal(t, "LIST", tsConfig.Buckets[0].Requests[1].Method) + assert.Equal(t, "configmaps", tsConfig.Buckets[0].Requests[1].Resource) + assert.Equal(t, "kube-system", tsConfig.Buckets[0].Requests[1].Namespace) + assert.Equal(t, 100, tsConfig.Buckets[0].Requests[1].Limit) + + assert.Equal(t, 1.0, tsConfig.Buckets[1].StartTime) + assert.Len(t, tsConfig.Buckets[1].Requests, 1) + assert.Equal(t, "POST", tsConfig.Buckets[1].Requests[0].Method) + assert.Equal(t, "configmaps", tsConfig.Buckets[1].Requests[0].Resource) + assert.Equal(t, "cm-1", tsConfig.Buckets[1].Requests[0].Name) + assert.Equal(t, `{"data": {"key": "value"}}`, tsConfig.Buckets[1].Requests[0].Body) + + assert.NoError(t, target.Validate()) +} diff --git a/api/types/weighted_random_config.go b/api/types/weighted_random_config.go new file mode 100644 index 00000000..deaa7af4 --- /dev/null +++ b/api/types/weighted_random_config.go @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package types + +import "fmt" + +// WeightedRandomConfig defines configuration for weighted-random execution mode. +type WeightedRandomConfig struct { + // Rate defines the maximum requests per second (zero is no limit). + Rate float64 `json:"rate" yaml:"rate" mapstructure:"rate"` + // Total defines the total number of requests. + Total int `json:"total" yaml:"total" mapstructure:"total"` + // Duration defines the running time in seconds. + Duration int `json:"duration" yaml:"duration" mapstructure:"duration"` + // Requests defines the different kinds of requests with weights. + Requests []*WeightedRequest `json:"requests" yaml:"requests" mapstructure:"requests"` +} + +// Ensure WeightedRandomConfig implements ModeConfig +func (*WeightedRandomConfig) isModeConfig() {} + +// GetOverridableFields implements ModeConfig for WeightedRandomConfig +func (c *WeightedRandomConfig) GetOverridableFields() []OverridableField { + return []OverridableField{ + { + Name: "rate", + Type: FieldTypeFloat64, + Description: "Maximum requests per second (0 means no limit)", + }, + { + Name: "total", + Type: FieldTypeInt, + Description: "Total number of requests to execute", + }, + { + Name: "duration", + Type: FieldTypeInt, + Description: "Duration in seconds (ignored if total is set)", + }, + } +} + +// ApplyOverrides implements ModeConfig for WeightedRandomConfig +func (c *WeightedRandomConfig) ApplyOverrides(overrides map[string]interface{}) error { + for key, value := range overrides { + switch key { + case "rate": + if v, ok := value.(float64); ok { + c.Rate = v + } else { + return fmt.Errorf("rate must be float64, got %T", value) + } + case "total": + if v, ok := value.(int); ok { + c.Total = v + } else { + return fmt.Errorf("total must be int, got %T", value) + } + case "duration": + if v, ok := value.(int); ok { + c.Duration = v + } else { + return fmt.Errorf("duration must be int, got %T", value) + } + default: + return fmt.Errorf("unknown override key for weighted-random mode: %s", key) + } + } + return nil +} + +// Validate implements ModeConfig for WeightedRandomConfig +func (c *WeightedRandomConfig) Validate(defaultOverrides map[string]interface{}) error { + // Check for conflicting Total and Duration settings + if c.Total > 0 && c.Duration > 0 { + // Both set - Duration is ignored + c.Duration = 0 + } + + // Apply defaults if both are zero + if c.Total == 0 && c.Duration == 0 { + if defaultTotal, ok := defaultOverrides["total"].(int); ok { + c.Total = defaultTotal + } + } + + return nil +} + +// ConfigureClientOptions implements ModeConfig for WeightedRandomConfig +func (c *WeightedRandomConfig) ConfigureClientOptions() ClientOptions { + return ClientOptions{ + QPS: c.Rate, + } +} diff --git a/api/types/weighted_random_config_test.go b/api/types/weighted_random_config_test.go new file mode 100644 index 00000000..1408f64a --- /dev/null +++ b/api/types/weighted_random_config_test.go @@ -0,0 +1,400 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestWeightedRandomConfigGetOverridableFields(t *testing.T) { + config := &WeightedRandomConfig{} + fields := config.GetOverridableFields() + + assert.Len(t, fields, 3) + + fieldMap := make(map[string]OverridableField) + for _, f := range fields { + fieldMap[f.Name] = f + } + + assert.Equal(t, FieldTypeFloat64, fieldMap["rate"].Type) + assert.Contains(t, fieldMap["rate"].Description, "requests per second") + + assert.Equal(t, FieldTypeInt, fieldMap["total"].Type) + assert.Contains(t, fieldMap["total"].Description, "Total number") + + assert.Equal(t, FieldTypeInt, fieldMap["duration"].Type) + assert.Contains(t, fieldMap["duration"].Description, "Duration") +} + +func TestWeightedRandomConfigApplyOverrides(t *testing.T) { + tests := map[string]struct { + initial WeightedRandomConfig + overrides map[string]interface{} + expected WeightedRandomConfig + err bool + }{ + "rate override": { + initial: WeightedRandomConfig{Rate: 100, Total: 1000}, + overrides: map[string]interface{}{ + "rate": float64(200), + }, + expected: WeightedRandomConfig{Rate: 200, Total: 1000}, + err: false, + }, + "total override": { + initial: WeightedRandomConfig{Rate: 100, Total: 1000}, + overrides: map[string]interface{}{ + "total": 2000, + }, + expected: WeightedRandomConfig{Rate: 100, Total: 2000}, + err: false, + }, + "duration override": { + initial: WeightedRandomConfig{Rate: 100, Duration: 60}, + overrides: map[string]interface{}{ + "duration": 120, + }, + expected: WeightedRandomConfig{Rate: 100, Duration: 120}, + err: false, + }, + "multiple overrides": { + initial: WeightedRandomConfig{Rate: 100, Total: 1000, Duration: 60}, + overrides: map[string]interface{}{ + "rate": float64(300), + "total": 3000, + "duration": 180, + }, + expected: WeightedRandomConfig{Rate: 300, Total: 3000, Duration: 180}, + err: false, + }, + "invalid rate type": { + initial: WeightedRandomConfig{Rate: 100}, + overrides: map[string]interface{}{ + "rate": "invalid", + }, + expected: WeightedRandomConfig{Rate: 100}, + err: true, + }, + "invalid total type": { + initial: WeightedRandomConfig{Total: 1000}, + overrides: map[string]interface{}{ + "total": "invalid", + }, + expected: WeightedRandomConfig{Total: 1000}, + err: true, + }, + "unknown key": { + initial: WeightedRandomConfig{Rate: 100}, + overrides: map[string]interface{}{ + "unknown": 123, + }, + expected: WeightedRandomConfig{Rate: 100}, + err: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + config := tc.initial + err := config.ApplyOverrides(tc.overrides) + if tc.err { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected.Rate, config.Rate) + assert.Equal(t, tc.expected.Total, config.Total) + assert.Equal(t, tc.expected.Duration, config.Duration) + } + }) + } +} + +func TestWeightedRandomConfigValidate(t *testing.T) { + tests := map[string]struct { + config WeightedRandomConfig + defaultOverrides map[string]interface{} + expectedTotal int + expectedDuration int + err bool + }{ + "total and duration set - duration ignored": { + config: WeightedRandomConfig{Total: 1000, Duration: 60}, + defaultOverrides: nil, + expectedTotal: 1000, + expectedDuration: 0, + err: false, + }, + "only total set": { + config: WeightedRandomConfig{Total: 1000}, + defaultOverrides: nil, + expectedTotal: 1000, + expectedDuration: 0, + err: false, + }, + "only duration set": { + config: WeightedRandomConfig{Duration: 60}, + defaultOverrides: nil, + expectedTotal: 0, + expectedDuration: 60, + err: false, + }, + "neither set - default total applied": { + config: WeightedRandomConfig{}, + defaultOverrides: map[string]interface{}{"total": 500}, + expectedTotal: 500, + expectedDuration: 0, + err: false, + }, + "neither set - no default": { + config: WeightedRandomConfig{}, + defaultOverrides: nil, + expectedTotal: 0, + expectedDuration: 0, + err: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + config := tc.config + err := config.Validate(tc.defaultOverrides) + if tc.err { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedTotal, config.Total) + assert.Equal(t, tc.expectedDuration, config.Duration) + } + }) + } +} + +func TestWeightedRandomConfigConfigureClientOptions(t *testing.T) { + tests := map[string]struct { + config WeightedRandomConfig + expectedQPS float64 + }{ + "rate set": { + config: WeightedRandomConfig{Rate: 100}, + expectedQPS: 100, + }, + "rate zero": { + config: WeightedRandomConfig{Rate: 0}, + expectedQPS: 0, + }, + "high rate": { + config: WeightedRandomConfig{Rate: 10000}, + expectedQPS: 10000, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + opts := tc.config.ConfigureClientOptions() + assert.Equal(t, tc.expectedQPS, opts.QPS) + }) + } +} + +func TestLoadProfileWeightedRandomUnmarshalFromYAML(t *testing.T) { + in := ` +version: 1 +description: test +spec: + conns: 2 + client: 1 + contentType: json + mode: weighted-random + modeConfig: + rate: 100 + total: 10000 + requests: + - staleGet: + group: core + version: v1 + resource: pods + namespace: default + name: x1 + shares: 100 + - quorumGet: + group: core + version: v1 + resource: configmaps + namespace: default + name: x2 + shares: 150 + - staleList: + group: core + version: v1 + resource: pods + namespace: default + selector: app=x2 + fieldSelector: spec.nodeName=x + shares: 200 + - quorumList: + group: core + version: v1 + resource: configmaps + namespace: default + limit: 10000 + selector: app=x3 + shares: 400 + - put: + group: core + version: v1 + resource: configmaps + namespace: kperf + name: kperf- + keySpaceSize: 1000 + valueSize: 1024 + shares: 1000 + - getPodLog: + namespace: default + name: hello + container: main + tailLines: 1000 + limitBytes: 1024 + shares: 10 + - watchList: + group: core + version: v1 + resource: pods + namespace: default + selector: app=x7 + fieldSelector: spec.nodeName=x + shares: 25 +` + + target := LoadProfile{} + require.NoError(t, yaml.Unmarshal([]byte(in), &target)) + assert.Equal(t, 1, target.Version) + assert.Equal(t, "test", target.Description) + assert.Equal(t, 2, target.Spec.Conns) + assert.Equal(t, ModeWeightedRandom, target.Spec.Mode) + + wrConfig, ok := target.Spec.ModeConfig.(*WeightedRandomConfig) + require.True(t, ok, "ModeConfig should be *WeightedRandomConfig") + require.NotNil(t, wrConfig) + + assert.Equal(t, float64(100), wrConfig.Rate) + assert.Equal(t, 10000, wrConfig.Total) + assert.Len(t, wrConfig.Requests, 7) + + assert.Equal(t, 100, wrConfig.Requests[0].Shares) + assert.NotNil(t, wrConfig.Requests[0].StaleGet) + assert.Equal(t, "pods", wrConfig.Requests[0].StaleGet.Resource) + assert.Equal(t, "v1", wrConfig.Requests[0].StaleGet.Version) + assert.Equal(t, "core", wrConfig.Requests[0].StaleGet.Group) + assert.Equal(t, "default", wrConfig.Requests[0].StaleGet.Namespace) + assert.Equal(t, "x1", wrConfig.Requests[0].StaleGet.Name) + + assert.NotNil(t, wrConfig.Requests[1].QuorumGet) + assert.Equal(t, 150, wrConfig.Requests[1].Shares) + + assert.Equal(t, 200, wrConfig.Requests[2].Shares) + assert.NotNil(t, wrConfig.Requests[2].StaleList) + assert.Equal(t, "pods", wrConfig.Requests[2].StaleList.Resource) + assert.Equal(t, "v1", wrConfig.Requests[2].StaleList.Version) + assert.Equal(t, "core", wrConfig.Requests[2].StaleList.Group) + assert.Equal(t, "default", wrConfig.Requests[2].StaleList.Namespace) + assert.Equal(t, 0, wrConfig.Requests[2].StaleList.Limit) + assert.Equal(t, "app=x2", wrConfig.Requests[2].StaleList.Selector) + assert.Equal(t, "spec.nodeName=x", wrConfig.Requests[2].StaleList.FieldSelector) + + assert.NotNil(t, wrConfig.Requests[3].QuorumList) + assert.Equal(t, 400, wrConfig.Requests[3].Shares) + + assert.Equal(t, 1000, wrConfig.Requests[4].Shares) + assert.NotNil(t, wrConfig.Requests[4].Put) + assert.Equal(t, "configmaps", wrConfig.Requests[4].Put.Resource) + assert.Equal(t, "v1", wrConfig.Requests[4].Put.Version) + assert.Equal(t, "core", wrConfig.Requests[4].Put.Group) + assert.Equal(t, "kperf", wrConfig.Requests[4].Put.Namespace) + assert.Equal(t, "kperf-", wrConfig.Requests[4].Put.Name) + assert.Equal(t, 1000, wrConfig.Requests[4].Put.KeySpaceSize) + assert.Equal(t, 1024, wrConfig.Requests[4].Put.ValueSize) + + assert.Equal(t, 10, wrConfig.Requests[5].Shares) + assert.NotNil(t, wrConfig.Requests[5].GetPodLog) + assert.Equal(t, "default", wrConfig.Requests[5].GetPodLog.Namespace) + assert.Equal(t, "hello", wrConfig.Requests[5].GetPodLog.Name) + assert.Equal(t, "main", wrConfig.Requests[5].GetPodLog.Container) + assert.Equal(t, int64(1000), *wrConfig.Requests[5].GetPodLog.TailLines) + assert.Equal(t, int64(1024), *wrConfig.Requests[5].GetPodLog.LimitBytes) + + assert.Equal(t, 25, wrConfig.Requests[6].Shares) + assert.NotNil(t, wrConfig.Requests[6].WatchList) + + assert.NoError(t, target.Validate()) +} + +func TestLoadProfileWeightedRandomUnmarshalFromYAML_LegacyFormat(t *testing.T) { + // Test backward compatibility with legacy format (no mode field) + in := ` +version: 1 +description: legacy format test +spec: + rate: 50 + total: 5000 + duration: 120 + conns: 4 + client: 2 + contentType: json + requests: + - staleGet: + group: core + version: v1 + resource: pods + namespace: default + name: test-pod + shares: 50 + - quorumList: + group: core + version: v1 + resource: configmaps + namespace: default + limit: 100 + shares: 100 +` + + target := LoadProfile{} + require.NoError(t, yaml.Unmarshal([]byte(in), &target)) + + assert.Equal(t, 1, target.Version) + assert.Equal(t, "legacy format test", target.Description) + assert.Equal(t, 4, target.Spec.Conns) + assert.Equal(t, 2, target.Spec.Client) + + // Should auto-migrate to weighted-random mode + assert.Equal(t, ModeWeightedRandom, target.Spec.Mode) + + wrConfig, ok := target.Spec.ModeConfig.(*WeightedRandomConfig) + require.True(t, ok, "ModeConfig should be *WeightedRandomConfig for legacy format") + require.NotNil(t, wrConfig) + + // Verify legacy fields are migrated + assert.Equal(t, float64(50), wrConfig.Rate) + assert.Equal(t, 5000, wrConfig.Total) + assert.Equal(t, 120, wrConfig.Duration) + assert.Len(t, wrConfig.Requests, 2) + + assert.Equal(t, 50, wrConfig.Requests[0].Shares) + assert.NotNil(t, wrConfig.Requests[0].StaleGet) + assert.Equal(t, "pods", wrConfig.Requests[0].StaleGet.Resource) + assert.Equal(t, "v1", wrConfig.Requests[0].StaleGet.Version) + assert.Equal(t, "default", wrConfig.Requests[0].StaleGet.Namespace) + assert.Equal(t, "test-pod", wrConfig.Requests[0].StaleGet.Name) + + assert.Equal(t, 100, wrConfig.Requests[1].Shares) + assert.NotNil(t, wrConfig.Requests[1].QuorumList) + assert.Equal(t, "configmaps", wrConfig.Requests[1].QuorumList.Resource) + assert.Equal(t, 100, wrConfig.Requests[1].QuorumList.Limit) + + assert.NoError(t, target.Validate()) +} From f1da76baa822a235896a240ebd3c936f4a24a852 Mon Sep 17 00:00:00 2001 From: JasonXuDeveloper Date: Tue, 16 Dec 2025 09:27:35 +1100 Subject: [PATCH 2/5] refactor: remove redundant interval field from TimeSeriesConfig The interval field in TimeSeriesConfig is redundant since timing is already specified via StartTime in each RequestBucket. Removing it simplifies the configuration. ## Changes - Remove Interval field from TimeSeriesConfig struct - Update GetOverridableFields() to return empty array (no CLI overrides) - Update ApplyOverrides() to reject any override attempts - Update all tests to remove interval references - Update comment examples to remove interval mentions ## Benefits - Simpler configuration - Less redundant data - Timing is determined by bucket StartTime values --- api/types/mode_config.go | 2 +- api/types/mode_config_test.go | 11 -------- api/types/timeseries_config.go | 28 +++++--------------- api/types/timeseries_config_test.go | 40 ++++++++--------------------- 4 files changed, 19 insertions(+), 62 deletions(-) diff --git a/api/types/mode_config.go b/api/types/mode_config.go index f84d59ce..7b5245ab 100644 --- a/api/types/mode_config.go +++ b/api/types/mode_config.go @@ -33,7 +33,7 @@ type ClientOptions struct { // OverridableField describes a config field that can be overridden via CLI flags. type OverridableField struct { - // Name is the field name (e.g., "rate", "total", "interval") + // Name is the field name (e.g., "rate", "total", "duration") Name string // Type describes the field type for CLI parsing Type FieldType diff --git a/api/types/mode_config_test.go b/api/types/mode_config_test.go index 4fbd33fe..e0874637 100644 --- a/api/types/mode_config_test.go +++ b/api/types/mode_config_test.go @@ -37,15 +37,6 @@ func TestBuildOverridesFromCLI(t *testing.T) { "duration": 120, }, }, - "time-series with interval": { - config: &TimeSeriesConfig{}, - cliValues: map[string]interface{}{ - "interval": "500ms", - }, - expectedResult: map[string]interface{}{ - "interval": "500ms", - }, - }, "no overrides set": { config: &WeightedRandomConfig{}, cliValues: map[string]interface{}{}, @@ -105,7 +96,6 @@ spec: client: 5 contentType: json modeConfig: - interval: "2s" buckets: - startTime: 0.0 requests: @@ -118,7 +108,6 @@ spec: validateFunc: func(t *testing.T, mc ModeConfig) { tsConfig, ok := mc.(*TimeSeriesConfig) require.True(t, ok) - assert.Equal(t, "2s", tsConfig.Interval) assert.Len(t, tsConfig.Buckets, 1) }, }, diff --git a/api/types/timeseries_config.go b/api/types/timeseries_config.go index 5c356fc0..7111df04 100644 --- a/api/types/timeseries_config.go +++ b/api/types/timeseries_config.go @@ -7,8 +7,6 @@ import "fmt" // TimeSeriesConfig defines configuration for time-series execution mode. type TimeSeriesConfig struct { - // Interval defines the time bucket size (e.g., "1s", "60s"). - Interval string `json:"interval" yaml:"interval" mapstructure:"interval"` // Buckets contains the time-bucketed requests. Buckets []RequestBucket `json:"buckets" yaml:"buckets" mapstructure:"buckets"` } @@ -54,28 +52,16 @@ func (*TimeSeriesConfig) isModeConfig() {} // GetOverridableFields implements ModeConfig for TimeSeriesConfig func (c *TimeSeriesConfig) GetOverridableFields() []OverridableField { - return []OverridableField{ - { - Name: "interval", - Type: FieldTypeString, - Description: "Time bucket interval (e.g., '1s', '100ms')", - }, - } + // Time-series mode has no CLI-overridable fields + // Bucket timing is defined in the load profile itself + return []OverridableField{} } // ApplyOverrides implements ModeConfig for TimeSeriesConfig func (c *TimeSeriesConfig) ApplyOverrides(overrides map[string]interface{}) error { - for key, value := range overrides { - switch key { - case "interval": - if v, ok := value.(string); ok { - c.Interval = v - } else { - return fmt.Errorf("interval must be string, got %T", value) - } - default: - return fmt.Errorf("unknown override key for time-series mode: %s", key) - } + // Time-series mode has no overridable fields + if len(overrides) > 0 { + return fmt.Errorf("time-series mode does not support CLI overrides") } return nil } @@ -83,7 +69,7 @@ func (c *TimeSeriesConfig) ApplyOverrides(overrides map[string]interface{}) erro // Validate implements ModeConfig for TimeSeriesConfig func (c *TimeSeriesConfig) Validate(defaultOverrides map[string]interface{}) error { // Time-series mode doesn't have conflicting settings or defaults - // Could add validation for interval format, bucket ordering, etc. + // Could add validation for bucket ordering, etc. return nil } diff --git a/api/types/timeseries_config_test.go b/api/types/timeseries_config_test.go index 36e89449..26e86c95 100644 --- a/api/types/timeseries_config_test.go +++ b/api/types/timeseries_config_test.go @@ -15,42 +15,27 @@ func TestTimeSeriesConfigGetOverridableFields(t *testing.T) { config := &TimeSeriesConfig{} fields := config.GetOverridableFields() - assert.Len(t, fields, 1) - assert.Equal(t, "interval", fields[0].Name) - assert.Equal(t, FieldTypeString, fields[0].Type) - assert.Contains(t, fields[0].Description, "Time bucket") + // Time-series mode has no CLI-overridable fields + assert.Len(t, fields, 0) } func TestTimeSeriesConfigApplyOverrides(t *testing.T) { tests := map[string]struct { initial TimeSeriesConfig overrides map[string]interface{} - expected TimeSeriesConfig err bool }{ - "interval override": { - initial: TimeSeriesConfig{Interval: "1s"}, - overrides: map[string]interface{}{ - "interval": "100ms", - }, - expected: TimeSeriesConfig{Interval: "100ms"}, - err: false, - }, - "invalid interval type": { - initial: TimeSeriesConfig{Interval: "1s"}, - overrides: map[string]interface{}{ - "interval": 123, - }, - expected: TimeSeriesConfig{Interval: "1s"}, - err: true, + "no overrides": { + initial: TimeSeriesConfig{}, + overrides: map[string]interface{}{}, + err: false, }, - "unknown key": { - initial: TimeSeriesConfig{Interval: "1s"}, + "any override fails": { + initial: TimeSeriesConfig{}, overrides: map[string]interface{}{ - "unknown": "value", + "interval": "100ms", }, - expected: TimeSeriesConfig{Interval: "1s"}, - err: true, + err: true, }, } @@ -62,14 +47,13 @@ func TestTimeSeriesConfigApplyOverrides(t *testing.T) { assert.Error(t, err) } else { assert.NoError(t, err) - assert.Equal(t, tc.expected.Interval, config.Interval) } }) } } func TestTimeSeriesConfigValidate(t *testing.T) { - config := &TimeSeriesConfig{Interval: "1s"} + config := &TimeSeriesConfig{} err := config.Validate(nil) assert.NoError(t, err) } @@ -90,7 +74,6 @@ spec: contentType: json mode: time-series modeConfig: - interval: "1s" buckets: - startTime: 0.0 requests: @@ -125,7 +108,6 @@ spec: require.True(t, ok, "ModeConfig should be *TimeSeriesConfig") require.NotNil(t, tsConfig) - assert.Equal(t, "1s", tsConfig.Interval) assert.Len(t, tsConfig.Buckets, 2) assert.Equal(t, 0.0, tsConfig.Buckets[0].StartTime) From 46f95fa8e12fd78c2b3b2d6bd48295c7ce97c7f5 Mon Sep 17 00:00:00 2001 From: JasonXuDeveloper Date: Tue, 16 Dec 2025 09:35:39 +1100 Subject: [PATCH 3/5] fix: update code to access fields via ModeConfig Update all code that previously accessed LoadProfileSpec fields directly (Rate, Total, Duration, Requests) to now access them via ModeConfig. ## Changes **request/schedule.go**: - Access rate, total, duration from WeightedRandomConfig **request/random.go**: - Access requests array from WeightedRandomConfig **cmd/kperf/commands/runner/runner.go**: - Access rate, total, duration from WeightedRandomConfig for CLI overrides **contrib/cmd/runkperf/commands/bench/utils.go**: - Access total, duration, requests from WeightedRandomConfig **contrib/cmd/runkperf/commands/warmup/command.go**: - Access total, rate from WeightedRandomConfig ## Validation All functions verify they're working with WeightedRandomConfig via type assertion, returning clear error messages if incorrect mode is used. The entire project now builds successfully with the new ModeConfig interface. --- cmd/kperf/commands/runner/runner.go | 31 +++++++++++++------ contrib/cmd/runkperf/commands/bench/utils.go | 19 ++++++++++-- .../cmd/runkperf/commands/warmup/command.go | 10 ++++-- request/random.go | 12 +++++-- request/schedule.go | 20 +++++++----- 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/cmd/kperf/commands/runner/runner.go b/cmd/kperf/commands/runner/runner.go index ecb56311..d94984ba 100644 --- a/cmd/kperf/commands/runner/runner.go +++ b/cmd/kperf/commands/runner/runner.go @@ -104,10 +104,17 @@ var runCommand = cli.Command{ } clientNum := profileCfg.Spec.Conns + + // Get wrConfig from ModeConfig + wrConfig, ok := profileCfg.Spec.ModeConfig.(*types.WeightedRandomConfig) + if !ok { + return fmt.Errorf("runner requires weighted-random mode") + } + restClis, err := request.NewClients(kubeCfgPath, clientNum, request.WithClientUserAgentOpt(cliCtx.String("user-agent")), - request.WithClientQPSOpt(profileCfg.Spec.Rate), + request.WithClientQPSOpt(wrConfig.Rate), request.WithClientContentTypeOpt(profileCfg.Spec.ContentType), request.WithClientDisableHTTP2Opt(profileCfg.Spec.DisableHTTP2), ) @@ -165,9 +172,15 @@ func loadConfig(cliCtx *cli.Context) (*types.LoadProfile, error) { return nil, fmt.Errorf("failed to unmarshal %s from yaml format: %w", cfgPath, err) } + // Get wrConfig from ModeConfig + wrConfig, ok := profileCfg.Spec.ModeConfig.(*types.WeightedRandomConfig) + if !ok { + return nil, fmt.Errorf("runner requires weighted-random mode") + } + // override value by flags if v := "rate"; cliCtx.IsSet(v) { - profileCfg.Spec.Rate = cliCtx.Float64(v) + wrConfig.Rate = cliCtx.Float64(v) } if v := "conns"; cliCtx.IsSet(v) || profileCfg.Spec.Conns == 0 { profileCfg.Spec.Conns = cliCtx.Int(v) @@ -176,18 +189,18 @@ func loadConfig(cliCtx *cli.Context) (*types.LoadProfile, error) { profileCfg.Spec.Client = cliCtx.Int(v) } if v := "total"; cliCtx.IsSet(v) { - profileCfg.Spec.Total = cliCtx.Int(v) + wrConfig.Total = cliCtx.Int(v) } if v := "duration"; cliCtx.IsSet(v) { - profileCfg.Spec.Duration = cliCtx.Int(v) + wrConfig.Duration = cliCtx.Int(v) } - if profileCfg.Spec.Total > 0 && profileCfg.Spec.Duration > 0 { - klog.Warningf("both total:%v and duration:%v are set, duration will be ignored\n", profileCfg.Spec.Total, profileCfg.Spec.Duration) - profileCfg.Spec.Duration = 0 + if wrConfig.Total > 0 && wrConfig.Duration > 0 { + klog.Warningf("both total:%v and duration:%v are set, duration will be ignored\n", wrConfig.Total, wrConfig.Duration) + wrConfig.Duration = 0 } - if profileCfg.Spec.Total == 0 && profileCfg.Spec.Duration == 0 { + if wrConfig.Total == 0 && wrConfig.Duration == 0 { // Use default total value - profileCfg.Spec.Total = cliCtx.Int("total") + wrConfig.Total = cliCtx.Int("total") } if v := "content-type"; cliCtx.IsSet(v) || profileCfg.Spec.ContentType == "" { profileCfg.Spec.ContentType = types.ContentType(cliCtx.String(v)) diff --git a/contrib/cmd/runkperf/commands/bench/utils.go b/contrib/cmd/runkperf/commands/bench/utils.go index 3995db55..fec77a59 100644 --- a/contrib/cmd/runkperf/commands/bench/utils.go +++ b/contrib/cmd/runkperf/commands/bench/utils.go @@ -151,9 +151,16 @@ func newLoadProfileFromEmbed(cliCtx *cli.Context, name string) (_name string, _s return fmt.Errorf("invalid total-requests value: %v", reqs) } reqsTime := cliCtx.Int("duration") + + // Get wrConfig from ModeConfig + wrConfig, ok := spec.Profile.Spec.ModeConfig.(*types.WeightedRandomConfig) + if !ok { + return fmt.Errorf("bench requires weighted-random mode") + } + if !cliCtx.IsSet("total") && reqsTime > 0 { reqs = 0 - spec.Profile.Spec.Duration = reqsTime + wrConfig.Duration = reqsTime } rgAffinity := cliCtx.GlobalString("rg-affinity") @@ -163,7 +170,7 @@ func newLoadProfileFromEmbed(cliCtx *cli.Context, name string) (_name string, _s } if reqs != 0 { - spec.Profile.Spec.Total = reqs + wrConfig.Total = reqs } spec.NodeAffinity = affinityLabels spec.Profile.Spec.ContentType = types.ContentType(cliCtx.String("content-type")) @@ -201,8 +208,14 @@ func tweakReadUpdateProfile(cliCtx *cli.Context, spec *types.RunnerGroupSpec) er namespace := cliCtx.String("read-update-namespace") configmapTotal := cliCtx.Int("read-update-configmap-total") + // Get wrConfig from ModeConfig + wrConfig, ok := spec.Profile.Spec.ModeConfig.(*types.WeightedRandomConfig) + if !ok { + return fmt.Errorf("tweakReadUpdateProfile requires weighted-random mode") + } + if namePattern != "" || ratio != 0 || namespace != "" || configmapTotal > 0 { - for _, r := range spec.Profile.Spec.Requests { + for _, r := range wrConfig.Requests { if r.Patch != nil { if namePattern != "" { r.Patch.Name = fmt.Sprintf("runkperf-cm-%s", namePattern) diff --git a/contrib/cmd/runkperf/commands/warmup/command.go b/contrib/cmd/runkperf/commands/warmup/command.go index e022e91f..dbc9664a 100644 --- a/contrib/cmd/runkperf/commands/warmup/command.go +++ b/contrib/cmd/runkperf/commands/warmup/command.go @@ -102,8 +102,14 @@ var Command = cli.Command{ return fmt.Errorf("failed to parse %s affinity: %w", rgAffinity, err) } - spec.Profile.Spec.Total = reqs - spec.Profile.Spec.Rate = rate + // Get wrConfig from ModeConfig + wrConfig, ok := spec.Profile.Spec.ModeConfig.(*types.WeightedRandomConfig) + if !ok { + return fmt.Errorf("warmup requires weighted-random mode") + } + + wrConfig.Total = reqs + wrConfig.Rate = rate spec.NodeAffinity = affinityLabels data, _ := yaml.Marshal(spec) diff --git a/request/random.go b/request/random.go index 32012e04..abf00108 100644 --- a/request/random.go +++ b/request/random.go @@ -41,9 +41,15 @@ func NewWeightedRandomRequests(spec *types.LoadProfileSpec) (*WeightedRandomRequ return nil, fmt.Errorf("invalid load profile spec: %v", err) } - shares := make([]int, 0, len(spec.Requests)) - reqBuilders := make([]RESTRequestBuilder, 0, len(spec.Requests)) - for _, r := range spec.Requests { + // Get requests from ModeConfig + wrConfig, ok := spec.ModeConfig.(*types.WeightedRandomConfig) + if !ok { + return nil, fmt.Errorf("weighted random requests requires weighted-random mode") + } + + shares := make([]int, 0, len(wrConfig.Requests)) + reqBuilders := make([]RESTRequestBuilder, 0, len(wrConfig.Requests)) + for _, r := range wrConfig.Requests { shares = append(shares, r.Shares) var builder RESTRequestBuilder diff --git a/request/schedule.go b/request/schedule.go index b6a09706..8e501840 100644 --- a/request/schedule.go +++ b/request/schedule.go @@ -40,7 +40,13 @@ func Schedule(ctx context.Context, spec *types.LoadProfileSpec, restCli []rest.I return nil, err } - qps := spec.Rate + // Get rate from ModeConfig + wrConfig, ok := spec.ModeConfig.(*types.WeightedRandomConfig) + if !ok { + return nil, errors.New("schedule requires weighted-random mode") + } + + qps := wrConfig.Rate if qps == 0 { qps = float64(math.MaxInt32) } @@ -118,21 +124,21 @@ func Schedule(ctx context.Context, spec *types.LoadProfileSpec, restCli []rest.I "clients", clients, "connections", len(restCli), "rate", qps, - "total", spec.Total, - "duration", spec.Duration, + "total", wrConfig.Total, + "duration", wrConfig.Duration, "http2", !spec.DisableHTTP2, "content-type", spec.ContentType, ) start := time.Now() - if spec.Duration > 0 { + if wrConfig.Duration > 0 { // If duration is set, we will run for duration. var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, time.Duration(spec.Duration)*time.Second) + ctx, cancel = context.WithTimeout(ctx, time.Duration(wrConfig.Duration)*time.Second) defer cancel() } - rndReqs.Run(ctx, spec.Total) + rndReqs.Run(ctx, wrConfig.Total) rndReqs.Stop() wg.Wait() @@ -142,7 +148,7 @@ func Schedule(ctx context.Context, spec *types.LoadProfileSpec, restCli []rest.I return &Result{ ResponseStats: responseStats, Duration: totalDuration, - Total: spec.Total, + Total: wrConfig.Total, }, nil } From 8d125440322d4919f8179e1bf9d665724f7ad83f Mon Sep 17 00:00:00 2001 From: JasonXuDeveloper Date: Tue, 16 Dec 2025 11:50:27 +1100 Subject: [PATCH 4/5] chore: remove load_traffic_test.go (split into separate mode config tests) --- api/types/load_traffic_test.go | 220 --------------------------------- 1 file changed, 220 deletions(-) delete mode 100644 api/types/load_traffic_test.go diff --git a/api/types/load_traffic_test.go b/api/types/load_traffic_test.go deleted file mode 100644 index 32178e7d..00000000 --- a/api/types/load_traffic_test.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -package types - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" -) - -func TestLoadProfileUnmarshalFromYAML(t *testing.T) { - in := ` -version: 1 -description: test -spec: - rate: 100 - total: 10000 - conns: 2 - client: 1 - contentType: json - requests: - - staleGet: - group: core - version: v1 - resource: pods - namespace: default - name: x1 - shares: 100 - - quorumGet: - group: core - version: v1 - resource: configmaps - namespace: default - name: x2 - shares: 150 - - staleList: - group: core - version: v1 - resource: pods - namespace: default - selector: app=x2 - fieldSelector: spec.nodeName=x - shares: 200 - - quorumList: - group: core - version: v1 - resource: configmaps - namespace: default - limit: 10000 - selector: app=x3 - shares: 400 - - put: - group: core - version: v1 - resource: configmaps - namespace: kperf - name: kperf- - keySpaceSize: 1000 - valueSize: 1024 - shares: 1000 - - getPodLog: - namespace: default - name: hello - container: main - tailLines: 1000 - limitBytes: 1024 - shares: 10 - - watchList: - group: core - version: v1 - resource: pods - namespace: default - selector: app=x2 - fieldSelector: spec.nodeName=x - shares: 250 -` - - target := LoadProfile{} - require.NoError(t, yaml.Unmarshal([]byte(in), &target)) - assert.Equal(t, 1, target.Version) - assert.Equal(t, "test", target.Description) - assert.Equal(t, float64(100), target.Spec.Rate) - assert.Equal(t, 10000, target.Spec.Total) - assert.Equal(t, 2, target.Spec.Conns) - assert.Len(t, target.Spec.Requests, 7) - - assert.Equal(t, 100, target.Spec.Requests[0].Shares) - assert.NotNil(t, target.Spec.Requests[0].StaleGet) - assert.Equal(t, "pods", target.Spec.Requests[0].StaleGet.Resource) - assert.Equal(t, "v1", target.Spec.Requests[0].StaleGet.Version) - assert.Equal(t, "core", target.Spec.Requests[0].StaleGet.Group) - assert.Equal(t, "default", target.Spec.Requests[0].StaleGet.Namespace) - assert.Equal(t, "x1", target.Spec.Requests[0].StaleGet.Name) - - assert.NotNil(t, target.Spec.Requests[1].QuorumGet) - assert.Equal(t, 150, target.Spec.Requests[1].Shares) - - assert.Equal(t, 200, target.Spec.Requests[2].Shares) - assert.NotNil(t, target.Spec.Requests[2].StaleList) - assert.Equal(t, "pods", target.Spec.Requests[2].StaleList.Resource) - assert.Equal(t, "v1", target.Spec.Requests[2].StaleList.Version) - assert.Equal(t, "core", target.Spec.Requests[2].StaleList.Group) - assert.Equal(t, "default", target.Spec.Requests[2].StaleList.Namespace) - assert.Equal(t, 0, target.Spec.Requests[2].StaleList.Limit) - assert.Equal(t, "app=x2", target.Spec.Requests[2].StaleList.Selector) - assert.Equal(t, "spec.nodeName=x", target.Spec.Requests[2].StaleList.FieldSelector) - - assert.NotNil(t, target.Spec.Requests[3].QuorumList) - assert.Equal(t, 400, target.Spec.Requests[3].Shares) - - assert.Equal(t, 1000, target.Spec.Requests[4].Shares) - assert.NotNil(t, target.Spec.Requests[4].Put) - assert.Equal(t, "configmaps", target.Spec.Requests[4].Put.Resource) - assert.Equal(t, "v1", target.Spec.Requests[4].Put.Version) - assert.Equal(t, "core", target.Spec.Requests[4].Put.Group) - assert.Equal(t, "kperf", target.Spec.Requests[4].Put.Namespace) - assert.Equal(t, "kperf-", target.Spec.Requests[4].Put.Name) - assert.Equal(t, 1000, target.Spec.Requests[4].Put.KeySpaceSize) - assert.Equal(t, 1024, target.Spec.Requests[4].Put.ValueSize) - - assert.Equal(t, 10, target.Spec.Requests[5].Shares) - assert.NotNil(t, target.Spec.Requests[5].GetPodLog) - assert.Equal(t, "default", target.Spec.Requests[5].GetPodLog.Namespace) - assert.Equal(t, "hello", target.Spec.Requests[5].GetPodLog.Name) - assert.Equal(t, "main", target.Spec.Requests[5].GetPodLog.Container) - assert.Equal(t, int64(1000), *target.Spec.Requests[5].GetPodLog.TailLines) - assert.Equal(t, int64(1024), *target.Spec.Requests[5].GetPodLog.LimitBytes) - - assert.Equal(t, 250, target.Spec.Requests[6].Shares) - assert.NotNil(t, target.Spec.Requests[6].WatchList) - - assert.NoError(t, target.Validate()) -} - -func TestWeightedRequest(t *testing.T) { - for _, r := range []struct { - name string - req *WeightedRequest - hasErr bool - }{ - { - name: "shares < 0", - req: &WeightedRequest{Shares: -1}, - hasErr: true, - }, - { - name: "no request setting", - req: &WeightedRequest{Shares: 10}, - hasErr: true, - }, - { - name: "empty version", - req: &WeightedRequest{ - Shares: 10, - StaleGet: &RequestGet{ - KubeGroupVersionResource: KubeGroupVersionResource{ - Resource: "pods", - }, - }, - }, - hasErr: true, - }, - { - name: "empty resource", - req: &WeightedRequest{ - Shares: 10, - StaleGet: &RequestGet{ - KubeGroupVersionResource: KubeGroupVersionResource{ - Group: "core", - Version: "v1", - }, - }, - }, - hasErr: true, - }, - { - name: "wrong limit", - req: &WeightedRequest{ - Shares: 10, - StaleList: &RequestList{ - KubeGroupVersionResource: KubeGroupVersionResource{ - Group: "core", - Version: "v1", - Resource: "pods", - }, - Limit: -1, - }, - }, - hasErr: true, - }, - { - name: "no error", - req: &WeightedRequest{ - Shares: 10, - StaleGet: &RequestGet{ - KubeGroupVersionResource: KubeGroupVersionResource{ - Group: "core", - Version: "v1", - Resource: "pods", - }, - Namespace: "default", - Name: "testing", - }, - }, - }, - } { - r := r - t.Run(r.name, func(t *testing.T) { - err := r.req.Validate() - if r.hasErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} From 409cb39be62cb0f012e06209f70d16d041867e4a Mon Sep 17 00:00:00 2001 From: JasonXuDeveloper Date: Tue, 16 Dec 2025 11:54:06 +1100 Subject: [PATCH 5/5] chore: restore TestWeightedRequest (was removed accidentally during merge) --- api/types/load_traffic_test.go | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 api/types/load_traffic_test.go diff --git a/api/types/load_traffic_test.go b/api/types/load_traffic_test.go new file mode 100644 index 00000000..66c40376 --- /dev/null +++ b/api/types/load_traffic_test.go @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWeightedRequest(t *testing.T) { + tests := map[string]struct { + req WeightedRequest + err bool + }{ + "shares < 0": { + req: WeightedRequest{ + Shares: -1, + }, + err: true, + }, + "no request setting": { + req: WeightedRequest{ + Shares: 100, + }, + err: true, + }, + "empty version": { + req: WeightedRequest{ + Shares: 100, + StaleList: &RequestList{ + KubeGroupVersionResource: KubeGroupVersionResource{ + Resource: "pods", + }, + }, + }, + err: true, + }, + "empty resource": { + req: WeightedRequest{ + Shares: 100, + StaleList: &RequestList{ + KubeGroupVersionResource: KubeGroupVersionResource{ + Version: "v1", + }, + }, + }, + err: true, + }, + "wrong limit": { + req: WeightedRequest{ + Shares: 100, + StaleList: &RequestList{ + KubeGroupVersionResource: KubeGroupVersionResource{ + Resource: "pods", + Version: "v1", + }, + Limit: -1, + }, + }, + err: true, + }, + "no error": { + req: WeightedRequest{ + Shares: 100, + StaleList: &RequestList{ + KubeGroupVersionResource: KubeGroupVersionResource{ + Resource: "pods", + Version: "v1", + }, + Limit: 0, + }, + }, + err: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := tc.req.Validate() + if tc.err { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +}