diff --git a/api/types/load_traffic.go b/api/types/load_traffic.go index 48a69cb2..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. @@ -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 + } + + // 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 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 } - 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 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 32178e7d..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 - 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 + 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..7b5245ab --- /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", "duration") + 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..e0874637 --- /dev/null +++ b/api/types/mode_config_test.go @@ -0,0 +1,162 @@ +// 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, + }, + }, + "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: + 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.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..7111df04 --- /dev/null +++ b/api/types/timeseries_config.go @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package types + +import "fmt" + +// TimeSeriesConfig defines configuration for time-series execution mode. +type TimeSeriesConfig struct { + // 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 { + // 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 { + // Time-series mode has no overridable fields + if len(overrides) > 0 { + return fmt.Errorf("time-series mode does not support CLI overrides") + } + 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 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..26e86c95 --- /dev/null +++ b/api/types/timeseries_config_test.go @@ -0,0 +1,133 @@ +// 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() + + // 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{} + err bool + }{ + "no overrides": { + initial: TimeSeriesConfig{}, + overrides: map[string]interface{}{}, + err: false, + }, + "any override fails": { + initial: TimeSeriesConfig{}, + overrides: map[string]interface{}{ + "interval": "100ms", + }, + 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) + } + }) + } +} + +func TestTimeSeriesConfigValidate(t *testing.T) { + config := &TimeSeriesConfig{} + 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: + 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.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()) +} 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 }