From e614eb8a1d6310c73c6b7378d04bafc4b400c0da Mon Sep 17 00:00:00 2001 From: Xinwei Date: Thu, 17 Sep 2026 20:05:02 +1000 Subject: [PATCH 1/2] feat: support node selector for post-delete pods Allow postDel workloads to configure pod scheduling with a key=value node selector. --- api/types/load_traffic.go | 10 ++- api/types/load_traffic_test.go | 70 +++++++++++++++++++ .../manifests/workload/pods/templates/pod.tpl | 6 ++ request/random.go | 44 +++++++----- 4 files changed, 111 insertions(+), 19 deletions(-) diff --git a/api/types/load_traffic.go b/api/types/load_traffic.go index a38ad82..b3aa4a1 100644 --- a/api/types/load_traffic.go +++ b/api/types/load_traffic.go @@ -202,7 +202,8 @@ type RequestPostDel struct { DeleteRatio float64 `json:"deleteRatio" yaml:"deleteRatio"` // PayloadSize is the size in bytes of a random padding string injected // into the created object (e.g. as a Pod env var value). 0 means no padding. - PayloadSize int `json:"payloadSize" yaml:"payloadSize"` + PayloadSize int `json:"payloadSize" yaml:"payloadSize"` + NodeSelector string `json:"nodeSelector,omitempty" yaml:"nodeSelector,omitempty"` } // Validate verifies fields of LoadProfile. @@ -426,5 +427,12 @@ func (r *RequestPostDel) Validate() error { return fmt.Errorf("payloadSize must >= 0: %v", r.PayloadSize) } + if r.NodeSelector != "" { + key, value, found := strings.Cut(r.NodeSelector, "=") + if !found || key == "" || value == "" || strings.Contains(value, "=") { + return fmt.Errorf("nodeSelector must use key=value format: %q", r.NodeSelector) + } + } + return nil } diff --git a/api/types/load_traffic_test.go b/api/types/load_traffic_test.go index 90456a8..3351bd7 100644 --- a/api/types/load_traffic_test.go +++ b/api/types/load_traffic_test.go @@ -218,3 +218,73 @@ func TestWeightedRequest(t *testing.T) { }) } } + +func TestRequestPostDelNodeSelectorValidation(t *testing.T) { + tests := []struct { + name string + nodeSelector string + wantErr bool + }{ + { + name: "not configured", + }, + { + name: "configured", + nodeSelector: "agentpool=benchmark", + }, + { + name: "missing separator", + nodeSelector: "agentpool", + wantErr: true, + }, + { + name: "missing key", + nodeSelector: "=benchmark", + wantErr: true, + }, + { + name: "missing value", + nodeSelector: "agentpool=", + wantErr: true, + }, + { + name: "multiple separators", + nodeSelector: "agentpool=benchmark=extra", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := RequestPostDel{ + KubeGroupVersionResource: KubeGroupVersionResource{ + Version: "v1", + Resource: "pods", + }, + NodeSelector: tt.nodeSelector, + } + + if tt.wantErr { + assert.Error(t, request.Validate()) + } else { + assert.NoError(t, request.Validate()) + } + }) + } +} + +func TestRequestPostDelNodeSelectorUnmarshalFromYAML(t *testing.T) { + input := ` +version: v1 +resource: pods +namespace: default +deleteRatio: 0.4 +payloadSize: 1024 +nodeSelector: agentpool=benchmark +` + + var request RequestPostDel + require.NoError(t, yaml.Unmarshal([]byte(input), &request)) + assert.Equal(t, "agentpool=benchmark", request.NodeSelector) + assert.NoError(t, request.Validate()) +} diff --git a/contrib/internal/manifests/workload/pods/templates/pod.tpl b/contrib/internal/manifests/workload/pods/templates/pod.tpl index 9cec5db..1c1b816 100644 --- a/contrib/internal/manifests/workload/pods/templates/pod.tpl +++ b/contrib/internal/manifests/workload/pods/templates/pod.tpl @@ -1,6 +1,8 @@ {{- $name:= .Values.namePattern }} {{- $namespace:= .Values.namespace }} {{- $payload:= .Values.payload }} +{{- $nodeSelectorKey:= .Values.nodeSelectorKey }} +{{- $nodeSelectorValue:= .Values.nodeSelectorValue }} apiVersion: v1 kind: Pod metadata: @@ -9,6 +11,10 @@ metadata: labels: app: fake-pod spec: + {{- if $nodeSelectorKey }} + nodeSelector: + "{{ $nodeSelectorKey }}": "{{ $nodeSelectorValue }}" + {{- end }} # Use pause image so pods reach Running with few events, avoiding an # ImagePullBackOff loop that keeps producing events and filling etcd. containers: diff --git a/request/random.go b/request/random.go index e2892ef..9ea1b82 100644 --- a/request/random.go +++ b/request/random.go @@ -446,13 +446,15 @@ func (b *requestPatchBuilder) Build(cli rest.Interface) Requester { } type requestPostDelBuilder struct { - version schema.GroupVersion - resource string - resourceVersion string - namespace string - deleteRatio float64 - payloadSize int - maxRetries int + version schema.GroupVersion + resource string + resourceVersion string + namespace string + deleteRatio float64 + payloadSize int + nodeSelectorKey string + nodeSelectorValue string + maxRetries int // Per-builder cache for created resources cache *Cache @@ -462,15 +464,19 @@ type requestPostDelBuilder struct { } func newRequestPostDelBuilder(src *types.RequestPostDel, resourceVersion string, maxRetries int) *requestPostDelBuilder { + nodeSelectorKey, nodeSelectorValue, _ := strings.Cut(src.NodeSelector, "=") + return &requestPostDelBuilder{ - version: schema.GroupVersion{Group: src.Group, Version: src.Version}, - resource: src.Resource, - resourceVersion: resourceVersion, - namespace: src.Namespace, - deleteRatio: src.DeleteRatio, - payloadSize: src.PayloadSize, - maxRetries: maxRetries, - cache: InitCache(), // Initialize the cache + version: schema.GroupVersion{Group: src.Group, Version: src.Version}, + resource: src.Resource, + resourceVersion: resourceVersion, + namespace: src.Namespace, + deleteRatio: src.DeleteRatio, + payloadSize: src.PayloadSize, + nodeSelectorKey: nodeSelectorKey, + nodeSelectorValue: nodeSelectorValue, + maxRetries: maxRetries, + cache: InitCache(), // Initialize the cache } } @@ -520,9 +526,11 @@ func (b *requestPostDelBuilder) Build(cli rest.Interface) Requester { name := fmt.Sprintf("%d-%d", timestamp, counter) body, err := utils.RenderTemplate(b.resource, map[string]interface{}{ - "namePattern": name, - "namespace": b.namespace, - "payload": randomPayload(b.payloadSize), + "namePattern": name, + "namespace": b.namespace, + "payload": randomPayload(b.payloadSize), + "nodeSelectorKey": b.nodeSelectorKey, + "nodeSelectorValue": b.nodeSelectorValue, }) if err != nil { panic(fmt.Errorf("failed to render %s template: %w", b.resource, err)) From b7c4f610dce34d40fb029c5a656bbd86fa022cd9 Mon Sep 17 00:00:00 2001 From: Xinwei Date: Thu, 22 Oct 2026 15:16:33 +1100 Subject: [PATCH 2/2] add pod option in postdel request to address comments --- api/types/load_traffic.go | 29 ++++-- api/types/load_traffic_test.go | 91 ++++++++++++++----- .../manifests/workload/pods/templates/pod.tpl | 9 +- request/random.go | 50 +++++----- 4 files changed, 121 insertions(+), 58 deletions(-) diff --git a/api/types/load_traffic.go b/api/types/load_traffic.go index b3aa4a1..d648224 100644 --- a/api/types/load_traffic.go +++ b/api/types/load_traffic.go @@ -8,7 +8,9 @@ import ( "fmt" "strings" + metav1validation "k8s.io/apimachinery/pkg/apis/meta/v1/validation" apitypes "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" ) // ContentType represents the format of response. @@ -202,8 +204,19 @@ type RequestPostDel struct { DeleteRatio float64 `json:"deleteRatio" yaml:"deleteRatio"` // PayloadSize is the size in bytes of a random padding string injected // into the created object (e.g. as a Pod env var value). 0 means no padding. - PayloadSize int `json:"payloadSize" yaml:"payloadSize"` - NodeSelector string `json:"nodeSelector,omitempty" yaml:"nodeSelector,omitempty"` + PayloadSize int `json:"payloadSize" yaml:"payloadSize"` + // Pod contains options for Pods created by POST operations. + Pod *PostDelPodOptions `json:"pod,omitempty" yaml:"pod,omitempty"` +} + +type PostDelPodOptions struct { + // NodeSelector schedules workload Pods separately from runner Pods, for + // example on a dedicated node pool: + // + // pod: + // nodeSelector: + // agentpool: benchmark + NodeSelector map[string]string `json:"nodeSelector,omitempty" yaml:"nodeSelector,omitempty"` } // Validate verifies fields of LoadProfile. @@ -427,10 +440,14 @@ func (r *RequestPostDel) Validate() error { return fmt.Errorf("payloadSize must >= 0: %v", r.PayloadSize) } - if r.NodeSelector != "" { - key, value, found := strings.Cut(r.NodeSelector, "=") - if !found || key == "" || value == "" || strings.Contains(value, "=") { - return fmt.Errorf("nodeSelector must use key=value format: %q", r.NodeSelector) + if r.Pod != nil && len(r.Pod.NodeSelector) > 0 { + if r.Group != "" || r.Version != "v1" || r.Resource != "pods" { + return fmt.Errorf("pod nodeSelector only supports core/v1/pods") + } + + errs := metav1validation.ValidateLabels(r.Pod.NodeSelector, field.NewPath("pod", "nodeSelector")) + if len(errs) > 0 { + return fmt.Errorf("invalid pod nodeSelector: %v", errs.ToAggregate()) } } diff --git a/api/types/load_traffic_test.go b/api/types/load_traffic_test.go index 3351bd7..fface1f 100644 --- a/api/types/load_traffic_test.go +++ b/api/types/load_traffic_test.go @@ -221,36 +221,77 @@ func TestWeightedRequest(t *testing.T) { func TestRequestPostDelNodeSelectorValidation(t *testing.T) { tests := []struct { - name string - nodeSelector string - wantErr bool + name string + group string + version string + resource string + pod *PostDelPodOptions + wantErr bool }{ { - name: "not configured", + name: "not configured", + version: "v1", + resource: "pods", }, { - name: "configured", - nodeSelector: "agentpool=benchmark", + name: "other resource without node selector", + group: "apps", + version: "v1", + resource: "deployments", }, { - name: "missing separator", - nodeSelector: "agentpool", - wantErr: true, + name: "configured", + version: "v1", + resource: "pods", + pod: &PostDelPodOptions{ + NodeSelector: map[string]string{"agentpool": "benchmark"}, + }, + }, + { + name: "unsupported group", + group: "apps", + version: "v1", + resource: "pods", + pod: &PostDelPodOptions{ + NodeSelector: map[string]string{"agentpool": "benchmark"}, + }, + wantErr: true, + }, + { + name: "unsupported version", + version: "v2", + resource: "pods", + pod: &PostDelPodOptions{ + NodeSelector: map[string]string{"agentpool": "benchmark"}, + }, + wantErr: true, }, { - name: "missing key", - nodeSelector: "=benchmark", - wantErr: true, + name: "unsupported resource", + version: "v1", + resource: "deployments", + pod: &PostDelPodOptions{ + NodeSelector: map[string]string{"agentpool": "benchmark"}, + }, + wantErr: true, }, { - name: "missing value", - nodeSelector: "agentpool=", - wantErr: true, + name: "invalid selector key", + version: "v1", + resource: "pods", + pod: &PostDelPodOptions{ + NodeSelector: map[string]string{"invalid key": "benchmark"}, + }, + wantErr: true, }, { - name: "multiple separators", - nodeSelector: "agentpool=benchmark=extra", - wantErr: true, + name: "invalid selector value", + version: "v1", + resource: "pods", + pod: &PostDelPodOptions{ + NodeSelector: map[string]string{"agentpool": "invalid value"}, + }, + wantErr: true, }, } @@ -258,10 +299,11 @@ func TestRequestPostDelNodeSelectorValidation(t *testing.T) { t.Run(tt.name, func(t *testing.T) { request := RequestPostDel{ KubeGroupVersionResource: KubeGroupVersionResource{ - Version: "v1", - Resource: "pods", + Group: tt.group, + Version: tt.version, + Resource: tt.resource, }, - NodeSelector: tt.nodeSelector, + Pod: tt.pod, } if tt.wantErr { @@ -280,11 +322,14 @@ resource: pods namespace: default deleteRatio: 0.4 payloadSize: 1024 -nodeSelector: agentpool=benchmark +pod: + nodeSelector: + agentpool: benchmark ` var request RequestPostDel require.NoError(t, yaml.Unmarshal([]byte(input), &request)) - assert.Equal(t, "agentpool=benchmark", request.NodeSelector) + require.NotNil(t, request.Pod) + assert.Equal(t, map[string]string{"agentpool": "benchmark"}, request.Pod.NodeSelector) assert.NoError(t, request.Validate()) } diff --git a/contrib/internal/manifests/workload/pods/templates/pod.tpl b/contrib/internal/manifests/workload/pods/templates/pod.tpl index 1c1b816..2703889 100644 --- a/contrib/internal/manifests/workload/pods/templates/pod.tpl +++ b/contrib/internal/manifests/workload/pods/templates/pod.tpl @@ -1,8 +1,7 @@ {{- $name:= .Values.namePattern }} {{- $namespace:= .Values.namespace }} {{- $payload:= .Values.payload }} -{{- $nodeSelectorKey:= .Values.nodeSelectorKey }} -{{- $nodeSelectorValue:= .Values.nodeSelectorValue }} +{{- $nodeSelector:= .Values.nodeSelector }} apiVersion: v1 kind: Pod metadata: @@ -11,9 +10,11 @@ metadata: labels: app: fake-pod spec: - {{- if $nodeSelectorKey }} + {{- if $nodeSelector }} nodeSelector: - "{{ $nodeSelectorKey }}": "{{ $nodeSelectorValue }}" + {{- range $key, $value := $nodeSelector }} + "{{ $key }}": "{{ $value }}" + {{- end }} {{- end }} # Use pause image so pods reach Running with few events, avoiding an # ImagePullBackOff loop that keeps producing events and filling etcd. diff --git a/request/random.go b/request/random.go index 9ea1b82..a5a88a9 100644 --- a/request/random.go +++ b/request/random.go @@ -446,15 +446,14 @@ func (b *requestPatchBuilder) Build(cli rest.Interface) Requester { } type requestPostDelBuilder struct { - version schema.GroupVersion - resource string - resourceVersion string - namespace string - deleteRatio float64 - payloadSize int - nodeSelectorKey string - nodeSelectorValue string - maxRetries int + version schema.GroupVersion + resource string + resourceVersion string + namespace string + deleteRatio float64 + payloadSize int + nodeSelector map[string]string + maxRetries int // Per-builder cache for created resources cache *Cache @@ -464,19 +463,21 @@ type requestPostDelBuilder struct { } func newRequestPostDelBuilder(src *types.RequestPostDel, resourceVersion string, maxRetries int) *requestPostDelBuilder { - nodeSelectorKey, nodeSelectorValue, _ := strings.Cut(src.NodeSelector, "=") + var nodeSelector map[string]string + if src.Pod != nil { + nodeSelector = src.Pod.NodeSelector + } return &requestPostDelBuilder{ - version: schema.GroupVersion{Group: src.Group, Version: src.Version}, - resource: src.Resource, - resourceVersion: resourceVersion, - namespace: src.Namespace, - deleteRatio: src.DeleteRatio, - payloadSize: src.PayloadSize, - nodeSelectorKey: nodeSelectorKey, - nodeSelectorValue: nodeSelectorValue, - maxRetries: maxRetries, - cache: InitCache(), // Initialize the cache + version: schema.GroupVersion{Group: src.Group, Version: src.Version}, + resource: src.Resource, + resourceVersion: resourceVersion, + namespace: src.Namespace, + deleteRatio: src.DeleteRatio, + payloadSize: src.PayloadSize, + nodeSelector: nodeSelector, + maxRetries: maxRetries, + cache: InitCache(), // Initialize the cache } } @@ -526,11 +527,10 @@ func (b *requestPostDelBuilder) Build(cli rest.Interface) Requester { name := fmt.Sprintf("%d-%d", timestamp, counter) body, err := utils.RenderTemplate(b.resource, map[string]interface{}{ - "namePattern": name, - "namespace": b.namespace, - "payload": randomPayload(b.payloadSize), - "nodeSelectorKey": b.nodeSelectorKey, - "nodeSelectorValue": b.nodeSelectorValue, + "namePattern": name, + "namespace": b.namespace, + "payload": randomPayload(b.payloadSize), + "nodeSelector": b.nodeSelector, }) if err != nil { panic(fmt.Errorf("failed to render %s template: %w", b.resource, err))