From bf7af65f693c0d91d5aefd1f3a5eb26912179b06 Mon Sep 17 00:00:00 2001 From: amh1k Date: Fri, 9 Oct 2026 23:41:12 +0500 Subject: [PATCH 1/8] feat(backend): support per-run node affinity for benchmark jobs Validate required and preferred scheduling rules, apply them to runner Jobs, and retain affinity snapshots in run status responses. Add API, store, and coordinator tests. Signed-off-by: amh1k --- backend/internal/api/node_affinity_test.go | 237 ++++++++++++++++++ backend/internal/api/runs.go | 89 ++++--- backend/internal/api/runs_test.go | 11 +- backend/internal/api/store.go | 20 +- backend/internal/api/store_test.go | 59 ++++- backend/internal/coordinator/jobs.go | 19 +- backend/internal/coordinator/jobs_test.go | 163 ++++++++++++ backend/internal/coordinator/node_affinity.go | 105 ++++++++ .../coordinator/node_affinity_test.go | 217 ++++++++++++++++ backend/internal/coordinator/runs.go | 6 + backend/internal/coordinator/runs_test.go | 69 +++++ 11 files changed, 938 insertions(+), 57 deletions(-) create mode 100644 backend/internal/api/node_affinity_test.go create mode 100644 backend/internal/coordinator/node_affinity.go create mode 100644 backend/internal/coordinator/node_affinity_test.go diff --git a/backend/internal/api/node_affinity_test.go b/backend/internal/api/node_affinity_test.go new file mode 100644 index 0000000..ad35570 --- /dev/null +++ b/backend/internal/api/node_affinity_test.go @@ -0,0 +1,237 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/openeverest/plugin-bench/backend/internal/coordinator" + "github.com/openeverest/plugin-bench/backend/internal/everest" + corev1 "k8s.io/api/core/v1" +) + +func testNodeAffinity() *corev1.NodeAffinity { + return &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{NodeSelectorTerms: []corev1.NodeSelectorTerm{ + {MatchExpressions: []corev1.NodeSelectorRequirement{{Key: "workload", Operator: corev1.NodeSelectorOpIn, Values: []string{"benchmark"}}}}, + {MatchFields: []corev1.NodeSelectorRequirement{{Key: "metadata.name", Operator: corev1.NodeSelectorOpIn, Values: []string{"node-1"}}}}, + }}, + PreferredDuringSchedulingIgnoredDuringExecution: []corev1.PreferredSchedulingTerm{{Weight: 75, Preference: corev1.NodeSelectorTerm{ + MatchExpressions: []corev1.NodeSelectorRequirement{{Key: "disk-type", Operator: corev1.NodeSelectorOpIn, Values: []string{"ssd"}}}, + MatchFields: []corev1.NodeSelectorRequirement{{Key: "metadata.name", Operator: corev1.NodeSelectorOpNotIn, Values: []string{"node-2"}}}, + }}}, + } +} + +func mutateTestNodeAffinity(affinity *corev1.NodeAffinity) { + affinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0].Values[0] = "changed" + affinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[1].MatchFields[0].Values[0] = "changed" + affinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 1 + affinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference.MatchExpressions[0].Values[0] = "changed" + affinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference.MatchFields[0].Values[0] = "changed" +} + +func bodyWithNodeAffinity(value string) string { + if value == "" { + return validCreateRunBody + } + return strings.TrimSuffix(validCreateRunBody, "}") + `,"nodeAffinity":` + value + "}" +} + +func TestCreateRunPreparesNodeAffinityAndKeepsStoredSnapshot(t *testing.T) { + combined := testNodeAffinity() + for name, input := range map[string]*corev1.NodeAffinity{ + "omitted": nil, + "null": nil, + "empty": {}, + "required": {RequiredDuringSchedulingIgnoredDuringExecution: combined.RequiredDuringSchedulingIgnoredDuringExecution}, + "preferred": {PreferredDuringSchedulingIgnoredDuringExecution: combined.PreferredDuringSchedulingIgnoredDuringExecution}, + "combined": combined, + } { + t.Run(name, func(t *testing.T) { + value, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + body := bodyWithNodeAffinity(string(value)) + if name == "omitted" { + body = validCreateRunBody + } + wantOptions := input.DeepCopy() + wantSnapshot := corev1.NodeAffinity{} + if name == "empty" { + wantOptions = nil + } else if input != nil { + wantSnapshot = *input.DeepCopy() + } + calls := make(chan coordinator.Options, 1) + release := make(chan struct{}) + store := NewRunStore() + a := NewAPI(func(context.Context, string, string, string, string) (*everest.Credentials, error) { + return testCredentials(), nil + }, nil, testRunnerFunc(func(_ context.Context, _ coordinator.Connection, options coordinator.Options) (coordinator.Result, error) { + calls <- options + <-release + return coordinator.Result{}, nil + }), store, context.Background()) + t.Cleanup(func() { + close(release) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := a.Shutdown(ctx); err != nil { + t.Error(err) + } + }) + r := httptest.NewRequest(http.MethodPost, "/api/runs", strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer test-token") + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.createRun(w, r) + if w.Code != http.StatusAccepted { + t.Fatalf("status=%d, body=%s", w.Code, w.Body.String()) + } + var accepted map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &accepted); err != nil { + t.Fatal(err) + } + if len(accepted) != 2 || accepted["id"] == "" || accepted["status"] != "running" { + t.Fatalf("acceptance contract changed: %v", accepted) + } + var options coordinator.Options + select { + case options = <-calls: + case <-time.After(time.Second): + t.Fatal("runner was not called") + } + if !reflect.DeepEqual(options.NodeAffinity, wantOptions) || options.Duration != 30 || options.Clients != 1 || options.Threads != 1 || options.Scale != 1 || options.Initialize { + t.Fatalf("unexpected prepared runner options: %+v", options) + } + if name == "combined" { + // A runner may mutate its own options. That must not alter history. + mutateTestNodeAffinity(options.NodeAffinity) + } + run, err := store.Get(accepted["id"]) + if err != nil || run.Status != RunStatusRunning || !reflect.DeepEqual(run.NodeAffinity, wantSnapshot) || !reflect.DeepEqual(run.Request.NodeAffinity, input) { + t.Fatalf("stored snapshots changed: run=%+v err=%v", run, err) + } + }) + } +} + +func TestCreateRunRejectsInvalidNodeAffinityBeforeAdmission(t *testing.T) { + oversized := testNodeAffinity() + values := make([]string, maxCreateRunRequestBytes) + for i := range values { + values[i] = "benchmark" + } + oversized.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0].Values = values + oversizedJSON, err := json.Marshal(oversized) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name, value, errorPath string + status int + }{ + {"no required terms", `{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[]}}`, "nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms", http.StatusBadRequest}, + {"empty required term", `{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{}]}}`, "nodeSelectorTerms[0]", http.StatusBadRequest}, + {"invalid operator", `{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{"matchExpressions":[{"key":"workload","operator":"Equals","values":["benchmark"]}]}]}}`, "matchExpressions[0].operator", http.StatusBadRequest}, + {"missing values", `{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{"matchExpressions":[{"key":"workload","operator":"In"}]}]}}`, "matchExpressions[0].values", http.StatusBadRequest}, + {"invalid field", `{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{"matchFields":[{"key":"metadata.namespace","operator":"In","values":["default"]}]}]}}`, "matchFields[0].key", http.StatusBadRequest}, + {"invalid weight", `{"preferredDuringSchedulingIgnoredDuringExecution":[{"weight":101,"preference":{"matchExpressions":[{"key":"disk-type","operator":"Exists"}]}}]}`, "preferredDuringSchedulingIgnoredDuringExecution[0].weight", http.StatusBadRequest}, + {"empty preference", `{"preferredDuringSchedulingIgnoredDuringExecution":[{"weight":1,"preference":{}}]}`, "preferredDuringSchedulingIgnoredDuringExecution[0].preference", http.StatusBadRequest}, + {"unknown affinity field", `{"nodeSelector":{"workload":"benchmark"}}`, "", http.StatusBadRequest}, + {"unknown nested field", `{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{"matchExpressions":[{"key":"workload","operator":"Exists","extra":true}]}]}}`, "", http.StatusBadRequest}, + {"wrong affinity type", `[]`, "", http.StatusBadRequest}, + {"wrong selector type", `{"requiredDuringSchedulingIgnoredDuringExecution":true}`, "", http.StatusBadRequest}, + {"wrong values type", `{"requiredDuringSchedulingIgnoredDuringExecution":{"nodeSelectorTerms":[{"matchExpressions":[{"key":"workload","operator":"In","values":"benchmark"}]}]}}`, "", http.StatusBadRequest}, + {"fractional weight", `{"preferredDuringSchedulingIgnoredDuringExecution":[{"weight":1.5,"preference":{}}]}`, "", http.StatusBadRequest}, + {"trailing JSON", `{} } {"extra":true`, "", http.StatusBadRequest}, + {"oversized affinity", string(oversizedJSON), "", http.StatusRequestEntityTooLarge}, + } { + t.Run(test.name, func(t *testing.T) { + var credentialsCalled, runnerCalled atomic.Bool + store := NewRunStore() + a := NewAPI(func(context.Context, string, string, string, string) (*everest.Credentials, error) { + credentialsCalled.Store(true) + return testCredentials(), nil + }, nil, testRunnerFunc(func(context.Context, coordinator.Connection, coordinator.Options) (coordinator.Result, error) { + runnerCalled.Store(true) + return coordinator.Result{}, nil + }), store, context.Background()) + r := httptest.NewRequest(http.MethodPost, "/api/runs", strings.NewReader(bodyWithNodeAffinity(test.value))) + r.Header.Set("Authorization", "Bearer test-token") + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + a.createRun(w, r) + if err := a.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } + if w.Code != test.status || !strings.Contains(w.Body.String(), test.errorPath) { + t.Fatalf("status=%d, body=%s; want %d and path %q", w.Code, w.Body.String(), test.status, test.errorPath) + } + if credentialsCalled.Load() || runnerCalled.Load() || len(a.runSlots) != 0 || len(store.runs) != 0 { + t.Fatal("invalid affinity caused credential lookup, execution, or run admission") + } + }) + } +} + +func TestGetRunReturnsStoredNodeAffinityForEveryStatus(t *testing.T) { + for _, status := range []RunStatus{RunStatusRunning, RunStatusSucceeded, RunStatusFailed} { + for name, input := range map[string]*corev1.NodeAffinity{"none": nil, "custom": testNodeAffinity()} { + t.Run(string(status)+"/"+name, func(t *testing.T) { + store := NewRunStore() + request := CreateRunRequest{Target: Target{K8sCluster: "local", Namespace: "dbs", Instance: "postgres-1"}, NodeAffinity: input.DeepCopy()} + if request.NodeAffinity != nil { + // Status must use the saved prepared snapshot, not recompute it + // from the separately retained original request. + request.NodeAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 5 + } + _, err := store.Create("run-1", request, input) + if err != nil { + t.Fatal(err) + } + if status != RunStatusRunning { + message := "" + if status == RunStatusFailed { + message = "benchmark failed" + } + if err := store.Complete("run-1", status, coordinator.Result{}, message); err != nil { + t.Fatal(err) + } + } + a := NewAPI(nil, func(context.Context, string, string, string, string) error { return nil }, nil, store, context.Background()) + mux := http.NewServeMux() + a.RegisterRoutes(mux) + r := httptest.NewRequest(http.MethodGet, "/api/runs/run-1", nil) + r.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status=%d, body=%s", w.Code, w.Body.String()) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &fields); err != nil { + t.Fatal(err) + } + if input == nil { + if string(fields["nodeAffinity"]) != "{}" { + t.Fatalf("unconstrained status must return {}, got %s", fields["nodeAffinity"]) + } + } else { + var got corev1.NodeAffinity + if err := json.Unmarshal(fields["nodeAffinity"], &got); err != nil || !reflect.DeepEqual(&got, input) { + t.Fatalf("stored affinity not returned: got=%+v err=%v", got, err) + } + } + }) + } + } +} diff --git a/backend/internal/api/runs.go b/backend/internal/api/runs.go index 75acc71..c0679e3 100644 --- a/backend/internal/api/runs.go +++ b/backend/internal/api/runs.go @@ -17,6 +17,7 @@ import ( "github.com/openeverest/plugin-bench/backend/internal/coordinator" "github.com/openeverest/plugin-bench/backend/internal/everest" + corev1 "k8s.io/api/core/v1" ) const maxCreateRunRequestBytes = 16 << 10 @@ -37,13 +38,14 @@ type Target struct { // CreateRunRequest contains the target and per-run benchmark settings. type CreateRunRequest struct { - Target Target `json:"target"` - Database string `json:"database"` - DurationSeconds int `json:"durationSeconds"` - Clients int `json:"clients"` - Threads int `json:"threads"` - Scale int `json:"scale"` - Initialize bool `json:"initialize"` + Target Target `json:"target"` + Database string `json:"database"` + DurationSeconds int `json:"durationSeconds"` + Clients int `json:"clients"` + Threads int `json:"threads"` + Scale int `json:"scale"` + Initialize bool `json:"initialize"` + NodeAffinity *corev1.NodeAffinity `json:"nodeAffinity,omitempty"` } // RunStatus describes the execution workflow. Running does not imply that @@ -52,27 +54,29 @@ type RunStatus string // Run is the record retained for status and result retrieval. type Run struct { - ID string `json:"id"` - Request CreateRunRequest `json:"request"` - Status RunStatus `json:"status"` - CreatedAt time.Time `json:"createdAt"` - CompletedAt *time.Time `json:"completedAt,omitempty"` - JobName string `json:"jobName,omitempty"` - Output string `json:"output,omitempty"` - OutputTruncated bool `json:"outputTruncated"` - Error string `json:"error,omitempty"` + ID string `json:"id"` + Request CreateRunRequest `json:"request"` + NodeAffinity corev1.NodeAffinity `json:"nodeAffinity"` + Status RunStatus `json:"status"` + CreatedAt time.Time `json:"createdAt"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + JobName string `json:"jobName,omitempty"` + Output string `json:"output,omitempty"` + OutputTruncated bool `json:"outputTruncated"` + Error string `json:"error,omitempty"` } type RunStatusResponse struct { - ID string `json:"id"` - Status RunStatus `json:"status"` - Database string `json:"database,omitempty"` - CreatedAt time.Time `json:"createdAt"` - CompletedAt *time.Time `json:"completedAt,omitempty"` - JobName string `json:"jobName,omitempty"` - Output string `json:"output,omitempty"` - OutputTruncated bool `json:"outputTruncated"` - Error string `json:"error,omitempty"` + ID string `json:"id"` + Status RunStatus `json:"status"` + Database string `json:"database,omitempty"` + NodeAffinity corev1.NodeAffinity `json:"nodeAffinity"` + CreatedAt time.Time `json:"createdAt"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + JobName string `json:"jobName,omitempty"` + Output string `json:"output,omitempty"` + OutputTruncated bool `json:"outputTruncated"` + Error string `json:"error,omitempty"` } func (a *API) createRun(w http.ResponseWriter, r *http.Request) { @@ -96,7 +100,8 @@ func (a *API) createRun(w http.ResponseWriter, r *http.Request) { writeError(w, status, err.Error()) return } - if err := validateCreateRunRequest(request); err != nil { + options, err := prepareRunOptions(request) + if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } @@ -151,20 +156,13 @@ func (a *API) createRun(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to create benchmark run") return } - run, err := a.store.Create(runID, request) + run, err := a.store.Create(runID, request, options.NodeAffinity) if err != nil { <-a.runSlots a.runs.Done() writeError(w, http.StatusInternalServerError, "failed to create benchmark run") return } - options := coordinator.Options{ - Duration: request.DurationSeconds, - Clients: request.Clients, - Threads: request.Threads, - Scale: request.Scale, - Initialize: request.Initialize, - } runCtx, cancel := context.WithCancel(a.lifecycleCtx) go a.executeRun(runCtx, cancel, run.ID, connection, options) @@ -296,18 +294,20 @@ func defaultDatabase(credentials *everest.Credentials) string { return strings.TrimSpace(database) } -func validateCreateRunRequest(request CreateRunRequest) error { +// prepareRunOptions validates HTTP input before credential lookup or admission. +// The coordinator reuses the same affinity validator to protect direct callers. +func prepareRunOptions(request CreateRunRequest) (coordinator.Options, error) { if strings.TrimSpace(request.Target.K8sCluster) == "" { - return errors.New("target.k8sCluster is required") + return coordinator.Options{}, errors.New("target.k8sCluster is required") } if strings.TrimSpace(request.Target.Namespace) == "" { - return errors.New("target.namespace is required") + return coordinator.Options{}, errors.New("target.namespace is required") } if strings.TrimSpace(request.Target.Instance) == "" { - return errors.New("target.instance is required") + return coordinator.Options{}, errors.New("target.instance is required") } if strings.Contains(request.Database, "=") || strings.HasPrefix(strings.ToLower(request.Database), "postgres://") || strings.HasPrefix(strings.ToLower(request.Database), "postgresql://") { - return errors.New("database must be a database name, not a connection string") + return coordinator.Options{}, errors.New("database must be a database name, not a connection string") } options := coordinator.Options{ @@ -317,7 +317,15 @@ func validateCreateRunRequest(request CreateRunRequest) error { Scale: request.Scale, Initialize: request.Initialize, } - return options.Validate() + if err := options.Validate(); err != nil { + return coordinator.Options{}, err + } + affinity, err := coordinator.PrepareNodeAffinity(request.NodeAffinity) + if err != nil { + return coordinator.Options{}, err + } + options.NodeAffinity = affinity + return options, nil } var errUnsupportedContentType = errors.New("Content-Type must be application/json") @@ -402,6 +410,7 @@ func (a *API) getRun(w http.ResponseWriter, r *http.Request) { ID: run.ID, Status: run.Status, Database: run.Request.Database, + NodeAffinity: run.NodeAffinity, CreatedAt: run.CreatedAt, CompletedAt: run.CompletedAt, JobName: run.JobName, diff --git a/backend/internal/api/runs_test.go b/backend/internal/api/runs_test.go index 6880017..d039345 100644 --- a/backend/internal/api/runs_test.go +++ b/backend/internal/api/runs_test.go @@ -418,10 +418,12 @@ func TestGetRun(t *testing.T) { t.Run(test.name, func(t *testing.T) { store := NewRunStore() if test.exists { + affinity := testNodeAffinity() run, err := store.Create("run-123", CreateRunRequest{ - Target: Target{K8sCluster: "local", Namespace: "databases", Instance: "postgres-1"}, - Database: "app", - }) + Target: Target{K8sCluster: "local", Namespace: "databases", Instance: "postgres-1"}, + Database: "app", + NodeAffinity: affinity, + }, affinity) if err != nil { t.Fatal(err) } @@ -480,6 +482,9 @@ func TestGetRun(t *testing.T) { t.Fatalf("missing access-check dependency was not logged: %q", logOutput.String()) } if test.wantHTTP != http.StatusOK { + if strings.Contains(w.Body.String(), "nodeAffinity") { + t.Fatal("unauthorized or unavailable status response exposed scheduling configuration") + } if strings.Contains(w.Body.String(), "test-token") || strings.Contains(w.Body.String(), "bench-password") { t.Fatal("error response exposed sensitive data") } diff --git a/backend/internal/api/store.go b/backend/internal/api/store.go index f4eee9c..1215208 100644 --- a/backend/internal/api/store.go +++ b/backend/internal/api/store.go @@ -7,6 +7,7 @@ import ( "time" "github.com/openeverest/plugin-bench/backend/internal/coordinator" + corev1 "k8s.io/api/core/v1" ) const completedRunRetention = time.Hour @@ -23,8 +24,9 @@ type RunStore struct { now func() time.Time } -// Create stores a new running record. -func (s *RunStore) Create(id string, request CreateRunRequest) (Run, error) { +// Create stores independent copies of the request and prepared affinity snapshot. +// A nil prepared affinity is retained as an empty object in status responses. +func (s *RunStore) Create(id string, request CreateRunRequest, nodeAffinity *corev1.NodeAffinity) (Run, error) { if strings.TrimSpace(id) == "" { return Run{}, errors.New("run ID is required") } @@ -35,12 +37,16 @@ func (s *RunStore) Create(id string, request CreateRunRequest) (Run, error) { if _, exists := s.runs[id]; exists { return Run{}, ErrRunExists } + request.NodeAffinity = request.NodeAffinity.DeepCopy() run := Run{ID: id, Request: request, Status: RunStatusRunning, CreatedAt: now.UTC()} + if nodeAffinity != nil { + run.NodeAffinity = *nodeAffinity.DeepCopy() + } if s.runs == nil { s.runs = make(map[string]Run) } s.runs[id] = run - return run, nil + return copyRun(run), nil } // Get returns a copy so callers cannot change the stored record without locking. @@ -54,11 +60,17 @@ func (s *RunStore) Get(id string) (Run, error) { if isExpired(run, s.clock()) { return Run{}, ErrRunNotFound } + return copyRun(run), nil +} + +func copyRun(run Run) Run { + run.Request.NodeAffinity = run.Request.NodeAffinity.DeepCopy() + run.NodeAffinity = *run.NodeAffinity.DeepCopy() if run.CompletedAt != nil { completedAt := *run.CompletedAt run.CompletedAt = &completedAt } - return run, nil + return run } func (s *RunStore) Complete(id string, status RunStatus, result coordinator.Result, message string) error { diff --git a/backend/internal/api/store_test.go b/backend/internal/api/store_test.go index d9711f0..3f84145 100644 --- a/backend/internal/api/store_test.go +++ b/backend/internal/api/store_test.go @@ -2,6 +2,7 @@ package api import ( "errors" + "reflect" "sync" "testing" "time" @@ -14,12 +15,12 @@ func TestRunStoreLifecycle(t *testing.T) { t.Run(string(status), func(t *testing.T) { s := NewRunStore() request := CreateRunRequest{Database: "benchmark"} - created, err := s.Create("run-1", request) + created, err := s.Create("run-1", request, nil) if err != nil || created.Status != RunStatusRunning || created.CreatedAt.IsZero() { t.Fatalf("Create = %+v, %v", created, err) } created.Request.Database = "changed" - if _, err := s.Create("run-1", CreateRunRequest{}); !errors.Is(err, ErrRunExists) { + if _, err := s.Create("run-1", CreateRunRequest{}, nil); !errors.Is(err, ErrRunExists) { t.Fatalf("duplicate Create: %v", err) } message := "" @@ -48,7 +49,7 @@ func TestRunStoreLifecycle(t *testing.T) { func TestRunStoreRejectsInvalidOperations(t *testing.T) { s := NewRunStore() - if _, err := s.Create(" ", CreateRunRequest{}); err == nil { + if _, err := s.Create(" ", CreateRunRequest{}, nil); err == nil { t.Fatal("accepted empty ID") } if _, err := s.Get("missing"); !errors.Is(err, ErrRunNotFound) { @@ -57,7 +58,7 @@ func TestRunStoreRejectsInvalidOperations(t *testing.T) { if err := s.Complete("missing", RunStatusSucceeded, coordinator.Result{}, ""); !errors.Is(err, ErrRunNotFound) { t.Fatal(err) } - if _, err := s.Create("run", CreateRunRequest{}); err != nil { + if _, err := s.Create("run", CreateRunRequest{}, nil); err != nil { t.Fatal(err) } for _, test := range []struct { @@ -76,9 +77,49 @@ func TestRunStoreRejectsInvalidOperations(t *testing.T) { } } +func TestRunStoreOwnsRequestAndNodeAffinitySnapshots(t *testing.T) { + for _, status := range []RunStatus{RunStatusSucceeded, RunStatusFailed} { + t.Run(string(status), func(t *testing.T) { + store := NewRunStore() + submitted := testNodeAffinity() + prepared := submitted.DeepCopy() + prepared.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 50 + wantRequest, wantSnapshot := submitted.DeepCopy(), prepared.DeepCopy() + created, err := store.Create("run-1", CreateRunRequest{NodeAffinity: submitted}, prepared) + if err != nil { + t.Fatal(err) + } + mutateTestNodeAffinity(submitted) + mutateTestNodeAffinity(prepared) + mutateTestNodeAffinity(created.Request.NodeAffinity) + mutateTestNodeAffinity(&created.NodeAffinity) + got, err := store.Get("run-1") + if err != nil || !reflect.DeepEqual(got.Request.NodeAffinity, wantRequest) || !reflect.DeepEqual(&got.NodeAffinity, wantSnapshot) { + t.Fatalf("Create exposed its stored pointers: run=%+v err=%v", got, err) + } + mutateTestNodeAffinity(got.Request.NodeAffinity) + if !reflect.DeepEqual(&got.NodeAffinity, wantSnapshot) { + t.Fatal("returned request and prepared snapshot share nested data") + } + mutateTestNodeAffinity(&got.NodeAffinity) + message := "" + if status == RunStatusFailed { + message = "benchmark failed" + } + if err := store.Complete("run-1", status, coordinator.Result{}, message); err != nil { + t.Fatal(err) + } + completed, err := store.Get("run-1") + if err != nil || completed.Status != status || !reflect.DeepEqual(completed.Request.NodeAffinity, wantRequest) || !reflect.DeepEqual(&completed.NodeAffinity, wantSnapshot) { + t.Fatalf("Get mutations or completion changed history: run=%+v err=%v", completed, err) + } + }) + } +} + func TestRunStoreConcurrentCompletion(t *testing.T) { s := NewRunStore() - if _, err := s.Create("run", CreateRunRequest{}); err != nil { + if _, err := s.Create("run", CreateRunRequest{}, nil); err != nil { t.Fatal(err) } var wg sync.WaitGroup @@ -119,7 +160,7 @@ func TestRunStoreExpiresCompletedRuns(t *testing.T) { s := NewRunStore() s.now = func() time.Time { return now } - if _, err := s.Create("completed", CreateRunRequest{}); err != nil { + if _, err := s.Create("completed", CreateRunRequest{}, nil); err != nil { t.Fatal(err) } if err := s.Complete("completed", test.status, coordinator.Result{}, test.message); err != nil { @@ -130,7 +171,7 @@ func TestRunStoreExpiresCompletedRuns(t *testing.T) { if _, err := s.Get("completed"); !errors.Is(err, ErrRunNotFound) { t.Fatalf("Get expired run error = %v, want %v", err, ErrRunNotFound) } - if _, err := s.Create("completed", CreateRunRequest{}); err != nil { + if _, err := s.Create("completed", CreateRunRequest{}, nil); err != nil { t.Fatalf("Create should prune the expired run before reusing its ID: %v", err) } }) @@ -142,11 +183,11 @@ func TestRunStoreRetainsRunningRunsPastRetention(t *testing.T) { s := NewRunStore() s.now = func() time.Time { return now } - if _, err := s.Create("running", CreateRunRequest{}); err != nil { + if _, err := s.Create("running", CreateRunRequest{}, nil); err != nil { t.Fatal(err) } now = now.Add(completedRunRetention + time.Second) - if _, err := s.Create("another", CreateRunRequest{}); err != nil { + if _, err := s.Create("another", CreateRunRequest{}, nil); err != nil { t.Fatal(err) } diff --git a/backend/internal/coordinator/jobs.go b/backend/internal/coordinator/jobs.go index 88df241..3e838ac 100644 --- a/backend/internal/coordinator/jobs.go +++ b/backend/internal/coordinator/jobs.go @@ -254,12 +254,21 @@ func (c *Coordinator) createBenchmarkResources(ctx context.Context, connection C return resources, err } + // Prepare the caller's rules once, before the first Kubernetes write. Options + // is a value copy, so replacing its affinity does not modify the caller. + preparedOptions := options + affinity, err := PrepareNodeAffinity(options.NodeAffinity) + if err != nil { + return resources, fmt.Errorf("invalid benchmark node affinity: %w", err) + } + preparedOptions.NodeAffinity = affinity + secret, err := c.createCredentialSecret(ctx, connection, labels) if err != nil { return resources, err } - job, err := c.createBenchmarkJob(ctx, secret, options, labels) + job, err := c.createBenchmarkJob(ctx, secret, preparedOptions, labels) if err == nil { return benchmarkResources{secret: secret, job: job}, nil } @@ -368,6 +377,7 @@ func (c *Coordinator) cleanupBenchmarkResourcesWithContext(ctx context.Context, // createBenchmarkJob builds and creates one runner Job in the configured // workload namespace. It does not wait for completion or read logs. +// Node affinity must already be prepared by createBenchmarkResources. func (c *Coordinator) createBenchmarkJob(ctx context.Context, secretRef SecretRef, options Options, labels map[string]string) (JobRef, error) { var reference JobRef if c == nil { @@ -418,6 +428,7 @@ func generateBenchmarkJobName() (string, error) { // newBenchmarkJob builds the per-run Job object. It only creates an in-memory // Kubernetes object; the caller is responsible for API calls and cleanup. +// Node affinity must be prepared before calling; the manifest owns its own copy. func newBenchmarkJob(config Config, name, secretName string, options Options, labels map[string]string) (*batchv1.Job, error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid coordinator configuration: %w", err) @@ -436,6 +447,11 @@ func newBenchmarkJob(config Config, name, secretName string, options Options, la return nil, err } + var affinity *corev1.Affinity + if options.NodeAffinity != nil { + affinity = &corev1.Affinity{NodeAffinity: options.NodeAffinity.DeepCopy()} + } + backoffLimit := int32(0) activeDeadlineSeconds := int64(config.ExecutionTimeout.Seconds()) return &batchv1.Job{ @@ -450,6 +466,7 @@ func newBenchmarkJob(config Config, name, secretName string, options Options, la Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: copyLabels(labels)}, Spec: corev1.PodSpec{ + Affinity: affinity, RestartPolicy: corev1.RestartPolicyNever, ServiceAccountName: config.RunnerServiceAccount, AutomountServiceAccountToken: boolPtr(false), diff --git a/backend/internal/coordinator/jobs_test.go b/backend/internal/coordinator/jobs_test.go index c76222a..48650f8 100644 --- a/backend/internal/coordinator/jobs_test.go +++ b/backend/internal/coordinator/jobs_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "reflect" "strings" "testing" "time" @@ -671,6 +672,168 @@ func TestCreateBenchmarkResourcesCreatesSecretBeforeJob(t *testing.T) { } } +func TestCreateBenchmarkResourcesAppliesNodeAffinity(t *testing.T) { + combined := validNodeAffinity() + for name, input := range map[string]*corev1.NodeAffinity{ + "omitted": nil, + "empty": {}, + "required": {RequiredDuringSchedulingIgnoredDuringExecution: combined.RequiredDuringSchedulingIgnoredDuringExecution}, + "preferred": {PreferredDuringSchedulingIgnoredDuringExecution: combined.PreferredDuringSchedulingIgnoredDuringExecution}, + "combined": combined, + } { + t.Run(name, func(t *testing.T) { + client := fake.NewSimpleClientset() + c, err := New(validConfig(), client) + if err != nil { + t.Fatal(err) + } + before := input.DeepCopy() + options := DefaultOptions() + options.NodeAffinity = input + resources, err := c.createBenchmarkResources(context.Background(), validConnection(), options, nil) + if err != nil { + t.Fatal(err) + } + job, err := client.BatchV1().Jobs(validConfig().WorkloadNamespace).Get(context.Background(), resources.job.Name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if name == "omitted" || name == "empty" { + if job.Spec.Template.Spec.Affinity != nil { + t.Fatal("unconstrained runs must omit Pod affinity") + } + } else if job.Spec.Template.Spec.Affinity == nil || !reflect.DeepEqual(job.Spec.Template.Spec.Affinity.NodeAffinity, before) { + t.Fatal("Job must preserve all submitted required/preferred rules") + } + if options.NodeAffinity != input || !reflect.DeepEqual(input, before) { + t.Fatal("resource creation modified submitted options") + } + }) + } +} + +func TestCreateBenchmarkResourcesRejectsNodeAffinityBeforeAPIWrites(t *testing.T) { + for _, input := range []*corev1.NodeAffinity{ + {RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{}}, + {RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{NodeSelectorTerms: []corev1.NodeSelectorTerm{{}}}}, + {PreferredDuringSchedulingIgnoredDuringExecution: []corev1.PreferredSchedulingTerm{{Weight: 101}}}, + } { + client := fake.NewSimpleClientset() + c, err := New(validConfig(), client) + if err != nil { + t.Fatal(err) + } + options := DefaultOptions() + options.NodeAffinity = input + _, err = c.createBenchmarkResources(context.Background(), validConnection(), options, nil) + if err == nil || !strings.Contains(err.Error(), "nodeAffinity.") { + t.Fatalf("want affinity validation error with a field path, got %v", err) + } + if len(client.Actions()) != 0 { + t.Fatalf("invalid affinity made Kubernetes calls: %v", client.Actions()) + } + } +} + +func TestCreateBenchmarkResourcesCopiesAffinityBeforeSecretCreation(t *testing.T) { + input := validNodeAffinity() + want := input.DeepCopy() + client := fake.NewSimpleClientset() + client.PrependReactor("create", "secrets", func(ktesting.Action) (bool, runtime.Object, error) { + // Changes after preparation must not affect the eventual Job manifest. + input.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0].Values[0] = "changed" + input.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 0 + return false, nil, nil + }) + c, err := New(validConfig(), client) + if err != nil { + t.Fatal(err) + } + options := DefaultOptions() + options.NodeAffinity = input + resources, err := c.createBenchmarkResources(context.Background(), validConnection(), options, nil) + if err != nil { + t.Fatal(err) + } + job, err := client.BatchV1().Jobs(validConfig().WorkloadNamespace).Get(context.Background(), resources.job.Name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if job.Spec.Template.Spec.Affinity == nil || !reflect.DeepEqual(job.Spec.Template.Spec.Affinity.NodeAffinity, want) { + t.Fatal("Job did not use the snapshot prepared before Secret creation") + } +} + +func TestConcurrentResourceCreationKeepsNodeAffinityIndependent(t *testing.T) { + client := fake.NewSimpleClientset() + createdJobs := make(chan *batchv1.Job, 2) + client.PrependReactor("create", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { + createdJobs <- action.(ktesting.CreateAction).GetObject().(*batchv1.Job) + return false, nil, nil + }) + c, err := New(validConfig(), client) + if err != nil { + t.Fatal(err) + } + input := validNodeAffinity() + want := input.DeepCopy() + options := DefaultOptions() + options.NodeAffinity = input + outcomes := make(chan error, 2) + for i := 0; i < 2; i++ { + go func() { + _, err := c.createBenchmarkResources(context.Background(), validConnection(), options, nil) + outcomes <- err + }() + } + for i := 0; i < 2; i++ { + if err := <-outcomes; err != nil { + t.Fatal(err) + } + } + first, second := <-createdJobs, <-createdJobs + if first.Name == second.Name || first.Spec.Template.Spec.Affinity == nil || second.Spec.Template.Spec.Affinity == nil { + t.Fatal("concurrent runs must create separate Jobs with their affinity") + } + first.Spec.Template.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0].Values[0] = "changed" + first.Spec.Template.Spec.Affinity.NodeAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 1 + if !reflect.DeepEqual(second.Spec.Template.Spec.Affinity.NodeAffinity, want) || !reflect.DeepEqual(input, want) { + t.Fatal("concurrent runs share mutable affinity state") + } +} + +func TestNewBenchmarkJobCopiesPreparedNodeAffinity(t *testing.T) { + input := validNodeAffinity() + want := input.DeepCopy() + prepared, err := PrepareNodeAffinity(input) + if err != nil { + t.Fatal(err) + } + options := DefaultOptions() + options.NodeAffinity = prepared + first, err := newBenchmarkJob(validConfig(), "job-1", "secret-1", options, nil) + if err != nil { + t.Fatal(err) + } + second, err := newBenchmarkJob(validConfig(), "job-2", "secret-2", options, nil) + if err != nil { + t.Fatal(err) + } + affinity := first.Spec.Template.Spec.Affinity.NodeAffinity + affinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0].Values[0] = "changed" + affinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[1].MatchFields[0].Values[0] = "changed" + affinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 1 + affinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference.MatchExpressions[0].Values[0] = "changed" + affinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference.MatchFields[0].Values[0] = "changed" + if !reflect.DeepEqual(prepared, want) || !reflect.DeepEqual(input, want) || !reflect.DeepEqual(second.Spec.Template.Spec.Affinity.NodeAffinity, want) { + t.Fatal("Job manifest shares nested affinity state with options or another Job") + } + prepared.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 2 + if !reflect.DeepEqual(second.Spec.Template.Spec.Affinity.NodeAffinity, want) { + t.Fatal("changing prepared options modified the Job manifest") + } +} + func TestCreateBenchmarkResourcesDeletesSecretWhenJobCreationFails(t *testing.T) { client := fake.NewSimpleClientset() client.PrependReactor("create", "secrets", func(action ktesting.Action) (bool, runtime.Object, error) { diff --git a/backend/internal/coordinator/node_affinity.go b/backend/internal/coordinator/node_affinity.go new file mode 100644 index 0000000..56c77ac --- /dev/null +++ b/backend/internal/coordinator/node_affinity.go @@ -0,0 +1,105 @@ +package coordinator + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// PrepareNodeAffinity validates per-run scheduling rules and returns a deep copy. +// Nil and empty input mean no affinity. Invalid rules are never silently dropped. +// It validates syntax, not whether any available node can satisfy the rules. +func PrepareNodeAffinity(affinity *corev1.NodeAffinity) (*corev1.NodeAffinity, error) { + if affinity == nil || (affinity.RequiredDuringSchedulingIgnoredDuringExecution == nil && + len(affinity.PreferredDuringSchedulingIgnoredDuringExecution) == 0) { + return nil, nil + } + + path := field.NewPath("nodeAffinity") + if required := affinity.RequiredDuringSchedulingIgnoredDuringExecution; required != nil { + termsPath := path.Child("requiredDuringSchedulingIgnoredDuringExecution", "nodeSelectorTerms") + if len(required.NodeSelectorTerms) == 0 { + return nil, field.Required(termsPath, "must contain at least one nonempty term") + } + for i, term := range required.NodeSelectorTerms { + if err := validateNodeAffinityTerm(term, termsPath.Index(i)); err != nil { + return nil, err + } + } + } + preferredPath := path.Child("preferredDuringSchedulingIgnoredDuringExecution") + for i, term := range affinity.PreferredDuringSchedulingIgnoredDuringExecution { + termPath := preferredPath.Index(i) + if term.Weight < 1 || term.Weight > 100 { + return nil, field.Invalid(termPath.Child("weight"), term.Weight, "must be between 1 and 100") + } + if err := validateNodeAffinityTerm(term.Preference, termPath.Child("preference")); err != nil { + return nil, err + } + } + return affinity.DeepCopy(), nil +} + +func validateNodeAffinityTerm(term corev1.NodeSelectorTerm, path *field.Path) error { + // Kubernetes treats empty required terms as matching no nodes. Reject them + // (and ineffective empty preferences) to catch accidental empty user input. + if len(term.MatchExpressions) == 0 && len(term.MatchFields) == 0 { + return field.Required(path, "must contain at least one matchExpression or matchField") + } + for i, requirement := range term.MatchExpressions { + if err := validateNodeLabelRequirement(requirement, path.Child("matchExpressions").Index(i)); err != nil { + return err + } + } + for i, requirement := range term.MatchFields { + if err := validateNodeFieldRequirement(requirement, path.Child("matchFields").Index(i)); err != nil { + return err + } + } + return nil +} + +func validateNodeLabelRequirement(requirement corev1.NodeSelectorRequirement, path *field.Path) error { + var operator selection.Operator + switch requirement.Operator { + case corev1.NodeSelectorOpIn: + operator = selection.In + case corev1.NodeSelectorOpNotIn: + operator = selection.NotIn + case corev1.NodeSelectorOpExists: + operator = selection.Exists + case corev1.NodeSelectorOpDoesNotExist: + operator = selection.DoesNotExist + case corev1.NodeSelectorOpGt: + operator = selection.GreaterThan + case corev1.NodeSelectorOpLt: + operator = selection.LessThan + default: + return field.NotSupported(path.Child("operator"), requirement.Operator, + []string{"In", "NotIn", "Exists", "DoesNotExist", "Gt", "Lt"}) + } + // Reuse Kubernetes label validation for keys, values, cardinality, and + // signed 64-bit integer thresholds rather than maintaining parallel rules. + _, err := labels.NewRequirement(requirement.Key, operator, requirement.Values, field.WithPath(path)) + return err +} + +func validateNodeFieldRequirement(requirement corev1.NodeSelectorRequirement, path *field.Path) error { + // Node field selectors support only metadata.name, with In/NotIn and + // exactly one valid node name. Label selector operators do not all apply. + if requirement.Key != "metadata.name" { + return field.NotSupported(path.Child("key"), requirement.Key, []string{"metadata.name"}) + } + if requirement.Operator != corev1.NodeSelectorOpIn && requirement.Operator != corev1.NodeSelectorOpNotIn { + return field.NotSupported(path.Child("operator"), requirement.Operator, []string{"In", "NotIn"}) + } + if len(requirement.Values) != 1 { + return field.Required(path.Child("values"), "must contain exactly one node name") + } + if problems := validation.IsDNS1123Subdomain(requirement.Values[0]); len(problems) > 0 { + return field.Invalid(path.Child("values").Index(0), requirement.Values[0], problems[0]) + } + return nil +} diff --git a/backend/internal/coordinator/node_affinity_test.go b/backend/internal/coordinator/node_affinity_test.go new file mode 100644 index 0000000..d557d47 --- /dev/null +++ b/backend/internal/coordinator/node_affinity_test.go @@ -0,0 +1,217 @@ +package coordinator + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestPrepareNodeAffinityEmpty(t *testing.T) { + for _, input := range []string{"null", "{}", `{"preferredDuringSchedulingIgnoredDuringExecution":[]}`} { + t.Run(input, func(t *testing.T) { + var affinity *corev1.NodeAffinity + if err := json.Unmarshal([]byte(input), &affinity); err != nil { + t.Fatal(err) + } + got, err := PrepareNodeAffinity(affinity) + if err != nil || got != nil { + t.Fatalf("PrepareNodeAffinity(%s) = %v, %v; want nil, nil", input, got, err) + } + }) + } +} + +func TestPrepareNodeAffinityPreservesRules(t *testing.T) { + combined := validNodeAffinity() + for name, input := range map[string]*corev1.NodeAffinity{ + "required only": {RequiredDuringSchedulingIgnoredDuringExecution: combined.RequiredDuringSchedulingIgnoredDuringExecution}, + "preferred only": {PreferredDuringSchedulingIgnoredDuringExecution: combined.PreferredDuringSchedulingIgnoredDuringExecution}, + "combined": combined, + } { + t.Run(name, func(t *testing.T) { + before := input.DeepCopy() + got, err := PrepareNodeAffinity(input) + if err != nil { + t.Fatal(err) + } + if got == input || !reflect.DeepEqual(got, before) || !reflect.DeepEqual(input, before) { + t.Fatal("rules must be preserved without modifying or returning the original input") + } + }) + } +} + +func TestPrepareNodeAffinityLabelOperators(t *testing.T) { + for _, test := range []struct { + operator corev1.NodeSelectorOperator + values []string + }{ + {corev1.NodeSelectorOpIn, []string{"benchmark", ""}}, + {corev1.NodeSelectorOpNotIn, []string{"database", "other"}}, + {corev1.NodeSelectorOpExists, nil}, + {corev1.NodeSelectorOpDoesNotExist, []string{}}, + {corev1.NodeSelectorOpGt, []string{"0"}}, + {corev1.NodeSelectorOpLt, []string{"9223372036854775807"}}, + } { + t.Run(string(test.operator), func(t *testing.T) { + input := validNodeAffinity() + input.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0] = corev1.NodeSelectorRequirement{ + Key: "example.com/workload", Operator: test.operator, Values: test.values, + } + if _, err := PrepareNodeAffinity(input); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestPrepareNodeAffinityRejectsInvalidRules(t *testing.T) { + const required = "nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms" + const expression = required + "[0].matchExpressions[0]" + const preferred = "nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution[0]" + const matchField = required + "[1].matchFields[0]" + type testCase struct { + name string + change func(*corev1.NodeAffinity) + path string + } + tests := []testCase{ + {"missing required terms", func(a *corev1.NodeAffinity) { a.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = nil }, required}, + {"empty required terms", func(a *corev1.NodeAffinity) { + a.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms = []corev1.NodeSelectorTerm{} + }, required}, + {"empty required term", func(a *corev1.NodeAffinity) { + a.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[1] = corev1.NodeSelectorTerm{} + }, required + "[1]"}, + {"empty preferred term", func(a *corev1.NodeAffinity) { + a.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference = corev1.NodeSelectorTerm{} + }, preferred + ".preference"}, + {"zero weight", func(a *corev1.NodeAffinity) { a.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 0 }, preferred + ".weight"}, + {"negative weight", func(a *corev1.NodeAffinity) { a.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = -1 }, preferred + ".weight"}, + {"excessive weight", func(a *corev1.NodeAffinity) { a.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 101 }, preferred + ".weight"}, + {"unknown field key", func(a *corev1.NodeAffinity) { + a.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[1].MatchFields[0].Key = "metadata.namespace" + }, matchField + ".key"}, + {"field exists operator", func(a *corev1.NodeAffinity) { + a.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[1].MatchFields[0].Operator = corev1.NodeSelectorOpExists + }, matchField + ".operator"}, + {"field missing value", func(a *corev1.NodeAffinity) { + a.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[1].MatchFields[0].Values = nil + }, matchField + ".values"}, + {"field multiple values", func(a *corev1.NodeAffinity) { + a.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[1].MatchFields[0].Values = []string{"node-1", "node-2"} + }, matchField + ".values"}, + {"field invalid node name", func(a *corev1.NodeAffinity) { + a.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[1].MatchFields[0].Values = []string{"Node 1"} + }, matchField + ".values[0]"}, + {"preferred invalid expression", func(a *corev1.NodeAffinity) { + a.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference.MatchExpressions[0].Key = "bad key" + }, preferred + ".preference.matchExpressions[0].key"}, + {"preferred invalid field", func(a *corev1.NodeAffinity) { + a.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference.MatchFields[0].Values = []string{""} + }, preferred + ".preference.matchFields[0].values[0]"}, + } + for _, test := range []struct { + name string + key string + operator corev1.NodeSelectorOperator + values []string + field string + }{ + {"missing key", "", corev1.NodeSelectorOpIn, []string{"benchmark"}, "key"}, + {"invalid key", "bad key", corev1.NodeSelectorOpIn, []string{"benchmark"}, "key"}, + {"invalid key prefix", "BAD.example/workload", corev1.NodeSelectorOpExists, nil, "key"}, + {"invalid value", "workload", corev1.NodeSelectorOpIn, []string{"bad value"}, "values[0]"}, + {"oversized value", "workload", corev1.NodeSelectorOpIn, []string{strings.Repeat("x", 64)}, "values[0]"}, + {"unknown operator", "workload", "Equals", []string{"benchmark"}, "operator"}, + {"missing operator", "workload", "", nil, "operator"}, + {"in without values", "workload", corev1.NodeSelectorOpIn, nil, "values"}, + {"notin without values", "workload", corev1.NodeSelectorOpNotIn, []string{}, "values"}, + {"exists with values", "workload", corev1.NodeSelectorOpExists, []string{"benchmark"}, "values"}, + {"doesnotexist with values", "workload", corev1.NodeSelectorOpDoesNotExist, []string{"benchmark"}, "values"}, + {"gt without values", "cores", corev1.NodeSelectorOpGt, nil, "values"}, + {"lt multiple values", "cores", corev1.NodeSelectorOpLt, []string{"1", "2"}, "values"}, + {"gt noninteger", "cores", corev1.NodeSelectorOpGt, []string{"many"}, "values[0]"}, + {"lt fractional", "cores", corev1.NodeSelectorOpLt, []string{"1.5"}, "values[0]"}, + {"gt overflow", "cores", corev1.NodeSelectorOpGt, []string{"9223372036854775808"}, "values[0]"}, + } { + tests = append(tests, testCase{test.name, func(a *corev1.NodeAffinity) { + a.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0] = corev1.NodeSelectorRequirement{ + Key: test.key, Operator: test.operator, Values: test.values, + } + }, expression + "." + test.field}) + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := validNodeAffinity() + test.change(input) + before := input.DeepCopy() + got, err := PrepareNodeAffinity(input) + if got != nil || err == nil || !strings.Contains(err.Error(), test.path) { + t.Fatalf("got %v, %v; want nil and error containing %q", got, err, test.path) + } + if !reflect.DeepEqual(input, before) { + t.Fatal("validation modified invalid input") + } + }) + } +} + +func TestPrepareNodeAffinityDeepCopy(t *testing.T) { + input := validNodeAffinity() + before := input.DeepCopy() + first, err := PrepareNodeAffinity(input) + if err != nil { + t.Fatal(err) + } + second, err := PrepareNodeAffinity(input) + if err != nil { + t.Fatal(err) + } + first.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0].Key = "changed" + first.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0].Values[0] = "changed" + first.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[1].MatchFields[0].Values[0] = "changed" + first.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 1 + first.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference.MatchExpressions[0].Values[0] = "changed" + first.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference.MatchFields[0].Values[0] = "changed" + if !reflect.DeepEqual(input, before) || !reflect.DeepEqual(second, before) { + t.Fatal("prepared affinity shares nested mutable state with input or another prepared run") + } + input.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms[0].MatchExpressions[0].Values[0] = "input changed" + input.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 2 + if !reflect.DeepEqual(second, before) { + t.Fatal("changing input modified prepared affinity") + } +} + +func validNodeAffinity() *corev1.NodeAffinity { + return &corev1.NodeAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{ + {MatchExpressions: []corev1.NodeSelectorRequirement{ + {Key: "workload", Operator: corev1.NodeSelectorOpIn, Values: []string{"benchmark", "general"}}, + {Key: "dedicated", Operator: corev1.NodeSelectorOpExists}, + }}, + {MatchFields: []corev1.NodeSelectorRequirement{ + {Key: "metadata.name", Operator: corev1.NodeSelectorOpIn, Values: []string{"node-1.example.com"}}, + }}, + }, + }, + PreferredDuringSchedulingIgnoredDuringExecution: []corev1.PreferredSchedulingTerm{ + {Weight: 100, Preference: corev1.NodeSelectorTerm{ + MatchExpressions: []corev1.NodeSelectorRequirement{ + {Key: "disk-type", Operator: corev1.NodeSelectorOpIn, Values: []string{"ssd"}}, + }, + MatchFields: []corev1.NodeSelectorRequirement{ + {Key: "metadata.name", Operator: corev1.NodeSelectorOpNotIn, Values: []string{"node-2"}}, + }, + }}, + {Weight: 1, Preference: corev1.NodeSelectorTerm{MatchExpressions: []corev1.NodeSelectorRequirement{ + {Key: "network", Operator: corev1.NodeSelectorOpIn, Values: []string{"fast"}}, + }}}, + }, + } +} diff --git a/backend/internal/coordinator/runs.go b/backend/internal/coordinator/runs.go index c30385c..5a859e5 100644 --- a/backend/internal/coordinator/runs.go +++ b/backend/internal/coordinator/runs.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "time" + + corev1 "k8s.io/api/core/v1" ) const failureLogTimeout = 10 * time.Second @@ -47,8 +49,12 @@ type Options struct { Threads int Scale int Initialize bool + // NodeAffinity is optional and is prepared before Kubernetes resource creation. + NodeAffinity *corev1.NodeAffinity } +// Validate checks scalar runner settings. Node affinity is validated and copied +// separately by PrepareNodeAffinity at the resource creation boundary. func (o Options) Validate() error { // Match the runner's supported positive 31-bit integer range. const maximum = 1<<31 - 1 diff --git a/backend/internal/coordinator/runs_test.go b/backend/internal/coordinator/runs_test.go index 0ece169..1b4784f 100644 --- a/backend/internal/coordinator/runs_test.go +++ b/backend/internal/coordinator/runs_test.go @@ -3,6 +3,7 @@ package coordinator import ( "context" "errors" + "reflect" "strings" "testing" "time" @@ -40,6 +41,74 @@ type diagnosticClient struct { check func(context.Context) } +func TestRunRejectsNodeAffinityBeforeAPIWrites(t *testing.T) { + client := fake.NewSimpleClientset() + c, err := New(validConfig(), client) + if err != nil { + t.Fatal(err) + } + options := DefaultOptions() + options.NodeAffinity = &corev1.NodeAffinity{RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{}} + result, err := c.Run(context.Background(), validConnection(), options) + if err == nil || !strings.Contains(err.Error(), "nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms") { + t.Fatalf("want required term validation error, got %v", err) + } + if result.JobName != "" || len(client.Actions()) != 0 { + t.Fatal("invalid affinity created resources or started execution") + } +} + +func TestRunAppliesNodeAffinityAndCleansUp(t *testing.T) { + client := fake.NewSimpleClientset() + input := validNodeAffinity() + want := input.DeepCopy() + var jobName string + client.PrependReactor("create", "secrets", func(action ktesting.Action) (bool, runtime.Object, error) { + action.(ktesting.CreateAction).GetObject().(*corev1.Secret).UID = "secret-uid" + return false, nil, nil + }) + client.PrependReactor("create", "jobs", func(action ktesting.Action) (bool, runtime.Object, error) { + job := action.(ktesting.CreateAction).GetObject().(*batchv1.Job) + if job.Spec.Template.Spec.Affinity == nil || !reflect.DeepEqual(job.Spec.Template.Spec.Affinity.NodeAffinity, want) { + t.Fatal("Run did not pass node affinity into the created Job") + } + job.UID = "job-uid" + jobName = job.Name + job.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}} + return false, nil, nil + }) + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + return true, &corev1.PodList{Items: []corev1.Pod{{ObjectMeta: metav1.ObjectMeta{ + Name: "runner-pod", Labels: map[string]string{batchv1.JobNameLabel: jobName}, + OwnerReferences: []metav1.OwnerReference{{APIVersion: "batch/v1", Kind: "Job", Name: jobName, UID: "job-uid", Controller: boolPtr(true)}}, + }}}}, nil + }) + c, err := New(validConfig(), client) + if err != nil { + t.Fatal(err) + } + options := DefaultOptions() + options.NodeAffinity = input + result, err := c.Run(context.Background(), validConnection(), options) + if err != nil { + t.Fatal(err) + } + if result.JobName != jobName || result.Output != "fake logs" { + t.Fatalf("unexpected run result: %+v", result) + } + if !reflect.DeepEqual(input, want) { + t.Fatal("Run modified submitted affinity") + } + jobDeleted, secretDeleted := false, false + for _, action := range client.Actions() { + jobDeleted = jobDeleted || action.Matches("delete", "jobs") + secretDeleted = secretDeleted || action.Matches("delete", "secrets") + } + if !jobDeleted || !secretDeleted { + t.Fatal("completed run did not clean up its Job and Secret") + } +} + func (c diagnosticClient) CoreV1() typedcorev1.CoreV1Interface { return diagnosticCore{CoreV1Interface: c.Clientset.CoreV1(), check: c.check} } From 997e1dcc25d094d1f73f0b30ee46105508c7a435 Mon Sep 17 00:00:00 2001 From: amh1k Date: Sat, 10 Oct 2026 11:28:25 +0500 Subject: [PATCH 2/8] feat(ui): configure and display benchmark job node affinity Add required and preferred rule editing, validate and serialize affinity, and display saved rules in run details. Cover validation, submission, status parsing, and UI behavior with frontend tests. Signed-off-by: amh1k --- package-lock.json | 820 ++++++++++++++++++++++- package.json | 4 +- src/BenchmarkForm.tsx | 20 +- src/BenchmarkNodeAffinityForm.tsx | 220 ++++++ src/BenchmarkNodeAffinitySummary.tsx | 56 ++ src/BenchmarkRunResult.tsx | 3 + src/benchmarkApi.ts | 6 + src/benchmarkFormValidation.ts | 9 +- src/benchmarkNodeAffinity.ts | 199 ++++++ tests/benchmarkApi.test.mjs | 21 + tests/benchmarkForm.test.mjs | 31 + tests/benchmarkNodeAffinity.test.mjs | 139 ++++ tests/benchmarkNodeAffinityForm.test.mjs | 216 ++++++ tests/benchmarkRunResult.test.mjs | 48 ++ tests/benchmarkStatus.test.mjs | 37 + 15 files changed, 1821 insertions(+), 8 deletions(-) create mode 100644 src/BenchmarkNodeAffinityForm.tsx create mode 100644 src/BenchmarkNodeAffinitySummary.tsx create mode 100644 src/benchmarkNodeAffinity.ts create mode 100644 tests/benchmarkNodeAffinity.test.mjs create mode 100644 tests/benchmarkNodeAffinityForm.test.mjs diff --git a/package-lock.json b/package-lock.json index a1f0b23..278093a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@mui/material": "^7.3.11", + "@mui/material": "7.3.11", "@openeverest/plugin-theme": "^0.1.0" }, "devDependencies": { @@ -19,12 +19,28 @@ "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^4.3.3", + "jsdom": "24.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-test-renderer": "18.3.1", "typescript": "^5.2.2", "vite": "^8.2.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -164,6 +180,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1218,6 +1349,23 @@ "vite": "^4 || ^5 || ^6 || ^7 || ^8" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -1233,6 +1381,20 @@ "npm": ">=6" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1251,6 +1413,19 @@ "node": ">=6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -1282,12 +1457,47 @@ "node": ">= 6" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1305,6 +1515,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1325,6 +1552,34 @@ "csstype": "^3.0.2" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -1334,6 +1589,16 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1343,6 +1608,35 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1379,6 +1673,23 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1403,6 +1714,87 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1430,6 +1822,60 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -1467,12 +1913,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1782,6 +2276,46 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1807,6 +2341,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1846,6 +2387,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1926,6 +2480,36 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1957,6 +2541,49 @@ "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", "license": "MIT" }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-shallow-renderer/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-test-renderer": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz", + "integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^18.3.1", + "react-shallow-renderer": "^16.15.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-test-renderer/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -1973,6 +2600,13 @@ "react-dom": ">=16.6.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -2037,6 +2671,33 @@ "@rolldown/binding-win32-x64-msvc": "1.2.7" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2083,6 +2744,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2100,6 +2768,35 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2114,6 +2811,27 @@ "node": ">=14.17" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", @@ -2191,6 +2909,106 @@ "optional": true } } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 3f507fc..5f6bbe8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "test:form": "tsc src/benchmarkFormValidation.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/form-test && node tests/benchmarkForm.test.mjs", + "test:form": "tsc src/BenchmarkForm.tsx --jsx react-jsx --target ES2020 --module ESNext --moduleResolution Bundler --esModuleInterop --skipLibCheck --outDir dist/form-test && node tests/benchmarkForm.test.mjs && node tests/benchmarkNodeAffinity.test.mjs && node tests/benchmarkNodeAffinityForm.test.mjs", "test:submission": "tsc src/benchmarkApi.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/submission-test && node tests/benchmarkApi.test.mjs", "test:status": "tsc src/benchmarkApi.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/status-test && node tests/benchmarkStatus.test.mjs", "test:polling": "tsc src/benchmarkRunPolling.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/polling-test && node tests/benchmarkRunPolling.test.mjs", @@ -25,8 +25,10 @@ "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "^4.3.3", + "jsdom": "24.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-test-renderer": "18.3.1", "typescript": "^5.2.2", "vite": "^8.2.0" } diff --git a/src/BenchmarkForm.tsx b/src/BenchmarkForm.tsx index d8302f0..3c9b2a5 100644 --- a/src/BenchmarkForm.tsx +++ b/src/BenchmarkForm.tsx @@ -13,12 +13,16 @@ import { TextField, Typography, } from '@mui/material'; -import { BenchmarkTargetSummary } from './BenchmarkTargetSummary'; -import { initialBenchmarkFormValues, validateBenchmarkForm } from './benchmarkFormValidation'; +import { BenchmarkTargetSummary } from './BenchmarkTargetSummary.js'; +import { initialBenchmarkFormValues, validateBenchmarkForm } from './benchmarkFormValidation.js'; import type { BenchmarkFormField, BenchmarkFormValues } from './benchmarkFormValidation'; import type { BenchmarkTarget } from './benchmarkTarget'; +import { createNodeAffinityDraft, prepareNodeAffinityDraft } from './benchmarkNodeAffinity.js'; +import type { NodeAffinityDraft } from './benchmarkNodeAffinity'; +import { BenchmarkNodeAffinityForm } from './BenchmarkNodeAffinityForm.js'; type NumericField = Exclude; +type FormState = BenchmarkFormValues & { nodeAffinity: NodeAffinityDraft }; const NUMERIC_FIELDS: ReadonlyArray<{ field: NumericField; label: string; hint: string }> = [ { field: 'durationSeconds', label: 'Duration (seconds)', hint: 'How long the benchmark runs.' }, @@ -34,12 +38,15 @@ interface BenchmarkFormProps { } export function BenchmarkForm({ target, isSubmitting, onSubmit }: BenchmarkFormProps) { - const [values, setValues] = useState({ ...initialBenchmarkFormValues }); + const [values, setValues] = useState(() => ({ + ...initialBenchmarkFormValues, nodeAffinity: createNodeAffinityDraft(), + })); const [touched, setTouched] = useState>>({}); const errors = validateBenchmarkForm(values); - const canSubmit = Object.keys(errors).length === 0 && !isSubmitting; + const affinity = prepareNodeAffinityDraft(values.nodeAffinity); + const canSubmit = Object.keys(errors).length === 0 && affinity.ok && !isSubmitting; - const updateValue = (key: K, value: BenchmarkFormValues[K]) => { + const updateValue = (key: K, value: FormState[K]) => { setValues(current => ({ ...current, [key]: value })); }; const markTouched = (field: BenchmarkFormField) => { @@ -121,6 +128,9 @@ export function BenchmarkForm({ target, isSubmitting, onSubmit }: BenchmarkFormP : 'Reuses the existing pgbench tables. The run fails if they don\'t exist yet.'} + updateValue('nodeAffinity', draft)} /> diff --git a/src/BenchmarkNodeAffinityForm.tsx b/src/BenchmarkNodeAffinityForm.tsx new file mode 100644 index 0000000..036d8a5 --- /dev/null +++ b/src/BenchmarkNodeAffinityForm.tsx @@ -0,0 +1,220 @@ +import { useId, useState } from 'react'; +import { + Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, Chip, + FormControlLabel, FormHelperText, MenuItem, Stack, Switch, TextField, Typography, +} from '@mui/material'; +import type { + NodeAffinityConditionDraft, NodeAffinityDraft, NodeAffinityErrors, + NodeAffinityPreferredDraft, NodeAffinityRequiredDraft, NodeSelectorOperator, +} from './benchmarkNodeAffinity'; + +const OPERATORS: ReadonlyArray<[NodeSelectorOperator, string]> = [ + ['In', 'In (one of)'], ['NotIn', 'NotIn (none of)'], ['Exists', 'Exists'], + ['DoesNotExist', 'DoesNotExist'], ['Gt', 'Gt (greater than)'], ['Lt', 'Lt (less than)'], +]; +const newCondition = (): NodeAffinityConditionDraft => ({ key: '', operator: 'In', values: [''] }); + +interface ConditionEditorProps { + condition: NodeAffinityConditionDraft; + label: string; + path: string; + errors: NodeAffinityErrors; + disabled: boolean; + onChange: (condition: NodeAffinityConditionDraft) => void; + onRemove: () => void; +} + +function ConditionEditor({ condition, label, path, errors, disabled, onChange, onRemove }: ConditionEditorProps) { + const id = useId(); + const existence = condition.operator === 'Exists' || condition.operator === 'DoesNotExist'; + const numeric = condition.operator === 'Gt' || condition.operator === 'Lt'; + const changeOperator = (operator: NodeSelectorOperator) => { + // Existence rules must not retain hidden values. Numeric rules have one threshold. + const values = operator === 'Exists' || operator === 'DoesNotExist' ? [] + : operator === 'Gt' || operator === 'Lt' ? [condition.values[0] ?? ''] + : condition.values.length > 0 ? condition.values : ['']; + onChange({ ...condition, operator, values }); + }; + return ( + + + + {label} + + + + onChange({ ...condition, key: event.target.value })} /> + changeOperator(event.target.value as NodeSelectorOperator)}> + {OPERATORS.map(([value, name]) => {name})} + + + {!existence && ( + + {condition.values.map((value, index) => ( + + onChange({ ...condition, + values: condition.values.map((item, i) => i === index ? event.target.value : item), + })} /> + {!numeric && ( + + )} + + ))} + {!numeric && } + + {errors[`${path}.values`] ?? (numeric ? 'Compare a numeric node-label value against this integer.' + : 'Each entry is an exact label value. Empty entries match an empty label value.')} + + + )} + {existence && errors[`${path}.values`] && {errors[`${path}.values`]}} + + + ); +} + +interface GroupEditorProps { + group: NodeAffinityRequiredDraft | NodeAffinityPreferredDraft; + kind: 'required' | 'preferred'; + index: number; + errors: NodeAffinityErrors; + disabled: boolean; + onChange: (group: NodeAffinityRequiredDraft | NodeAffinityPreferredDraft) => void; + onRemove: () => void; +} + +function GroupEditor({ group, kind, index, errors, disabled, onChange, onRemove }: GroupEditorProps) { + const id = useId(); + const path = `${kind}[${index}]`; + const title = `${kind === 'required' ? 'Required' : 'Preferred'} group ${index + 1}`; + return ( + + {title} + + + {'weight' in group && onChange({ ...group, weight: event.target.value })} />} + {errors[path] && {errors[path]}} + {group.matchExpressions.map((condition, conditionIndex) => ( + onChange({ ...group, matchExpressions: group.matchExpressions.map( + (item, i) => i === conditionIndex ? next : item), + })} + onRemove={() => onChange({ ...group, + matchExpressions: group.matchExpressions.filter((_, i) => i !== conditionIndex), + })} /> + ))} + + + + ); +} + +interface BenchmarkNodeAffinityFormProps { + draft: NodeAffinityDraft; + errors: NodeAffinityErrors; + disabled?: boolean; + onChange: (draft: NodeAffinityDraft) => void; +} + +export function BenchmarkNodeAffinityForm({ draft, errors, disabled = false, onChange }: BenchmarkNodeAffinityFormProps) { + const id = useId(); + const [expanded, setExpanded] = useState(false); + const errorCount = Object.keys(errors).length; + return ( + setExpanded(open)}> + + + + onChange({ ...draft, enabled: event.target.checked })} /> + } /> + + Rules apply to benchmark runner nodes in the cluster hosting this plugin, not the database instance. + Leaving affinity off uses normal Kubernetes scheduling. + + {draft.enabled && <> + + Required rules can keep the Job waiting if no eligible node is available. + Affinity does not override available resources, taints, or other scheduling constraints. + + {errors.nodeAffinity && {errors.nodeAffinity}} + Required rules + + A node must match all conditions (AND) in at least one required group (OR). + + {draft.required.map((group, index) => ( + + {index > 0 && OR} + onChange({ ...draft, + required: draft.required.map((item, i) => i === index ? next : item), + })} + onRemove={() => onChange({ ...draft, required: draft.required.filter((_, i) => i !== index) })} /> + + ))} + + Preferred rules + + A group favors nodes matching all its conditions. Higher weights give a stronger preference, + but nonmatching eligible nodes may still run the Job. + + {draft.preferred.map((group, index) => ( + onChange({ ...draft, preferred: draft.preferred.map( + (item, i) => i === index ? { ...group, ...next } : item), + })} + onRemove={() => onChange({ ...draft, preferred: draft.preferred.filter((_, i) => i !== index) })} /> + ))} + + } + + + + ); +} diff --git a/src/BenchmarkNodeAffinitySummary.tsx b/src/BenchmarkNodeAffinitySummary.tsx new file mode 100644 index 0000000..204fb94 --- /dev/null +++ b/src/BenchmarkNodeAffinitySummary.tsx @@ -0,0 +1,56 @@ +import { Box, Stack, Typography } from '@mui/material'; +import type { NodeAffinity, NodeSelectorRequirement, NodeSelectorTerm } from './benchmarkNodeAffinity'; + +function describeRequirement(requirement: NodeSelectorRequirement, field: boolean): string { + const prefix = field ? 'Field' : 'Label'; + const values = requirement.values ?? []; + const suffix = values.length > 0 ? ` ${JSON.stringify(values)}` : ''; + return `${prefix}: ${requirement.key} ${requirement.operator}${suffix}`; +} + +function TermSummary({ term }: { term: NodeSelectorTerm }) { + const conditions = [ + ...(term.matchExpressions ?? []).map(item => describeRequirement(item, false)), + ...(term.matchFields ?? []).map(item => describeRequirement(item, true)), + ]; + return ( + + {conditions.map((condition, index) => ( + + {condition} + + ))} + + ); +} + +export function BenchmarkNodeAffinitySummary({ affinity }: { affinity: NodeAffinity }) { + const required = affinity.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms ?? []; + const preferred = affinity.preferredDuringSchedulingIgnoredDuringExecution ?? []; + return ( + + Configured node affinity + {required.length === 0 && preferred.length === 0 ? ( + No node affinity configured. + ) : <> + + Saved runner placement rules, not the node ultimately selected by Kubernetes. + + {required.length > 0 && <> + Required: match all conditions in at least one group. + {required.map((term, index) => + {index > 0 ? 'OR — ' : ''}Required group {index + 1} (AND) + + )} + } + {preferred.length > 0 && <> + Preferred: weighted preferences, not requirements. + {preferred.map((item, index) => + Preferred group {index + 1} — weight {item.weight} (AND) + + )} + } + } + + ); +} diff --git a/src/BenchmarkRunResult.tsx b/src/BenchmarkRunResult.tsx index 76705e2..ddbdaa5 100644 --- a/src/BenchmarkRunResult.tsx +++ b/src/BenchmarkRunResult.tsx @@ -13,6 +13,7 @@ import { } from '@mui/material'; import type { BenchmarkRun, BenchmarkRunStatus } from './benchmarkApi'; import type { PollingFeedback } from './benchmarkRunPolling'; +import { BenchmarkNodeAffinitySummary } from './BenchmarkNodeAffinitySummary.js'; const STATUS_LABELS: Record = { running: 'Running', @@ -104,6 +105,8 @@ export function BenchmarkRunResult({ id, run, feedback, onRetry, onDismiss }: Be {run?.status === 'failed' && {run.error}} + {run?.nodeAffinity !== undefined && } + {completed && ( diff --git a/src/benchmarkApi.ts b/src/benchmarkApi.ts index 0314f3c..09d2458 100644 --- a/src/benchmarkApi.ts +++ b/src/benchmarkApi.ts @@ -1,5 +1,7 @@ import type { PluginApi } from '@openeverest/plugin-sdk'; import type { BenchmarkTarget } from './benchmarkTarget'; +import { isNodeAffinity } from './benchmarkNodeAffinity.js'; +import type { NodeAffinity } from './benchmarkNodeAffinity'; export type CreateBenchmarkRunRequest = { target: BenchmarkTarget; @@ -10,6 +12,7 @@ export type CreateBenchmarkRunRequest = { threads: number; scale: number; initialize: boolean; + nodeAffinity?: NodeAffinity; }; export type CreateBenchmarkRunResponse = { @@ -24,6 +27,8 @@ export type BenchmarkRun = { status: BenchmarkRunStatus; database?: string; createdAt: string; + /** Absent on older backends; an empty object means no configured affinity. */ + nodeAffinity?: NodeAffinity; completedAt?: string; jobName?: string; output?: string; @@ -104,6 +109,7 @@ function isBenchmarkRun(value: unknown, id: string): value is BenchmarkRun { return run.id === id && (run.status === 'running' || run.status === 'succeeded' || run.status === 'failed') && timestamp(run.createdAt) && typeof run.outputTruncated === 'boolean' && + (run.nodeAffinity === undefined || isNodeAffinity(run.nodeAffinity)) && (run.completedAt === undefined || timestamp(run.completedAt)) && (run.status === 'running' || timestamp(run.completedAt)) && ['database', 'jobName', 'output', 'error'].every(key => run[key] === undefined || typeof run[key] === 'string') && diff --git a/src/benchmarkFormValidation.ts b/src/benchmarkFormValidation.ts index 10d2bbd..62f498c 100644 --- a/src/benchmarkFormValidation.ts +++ b/src/benchmarkFormValidation.ts @@ -1,5 +1,7 @@ import type { CreateBenchmarkRunRequest } from './benchmarkApi'; import type { BenchmarkTarget } from './benchmarkTarget'; +import { createNodeAffinityDraft, prepareNodeAffinityDraft } from './benchmarkNodeAffinity.js'; +import type { NodeAffinityDraft } from './benchmarkNodeAffinity'; export type BenchmarkFormValues = { database: string; @@ -8,6 +10,7 @@ export type BenchmarkFormValues = { threads: string; scale: string; initialize: boolean; + nodeAffinity?: NodeAffinityDraft; }; export const initialBenchmarkFormValues: BenchmarkFormValues = { @@ -17,9 +20,10 @@ export const initialBenchmarkFormValues: BenchmarkFormValues = { threads: '1', scale: '1', initialize: true, + nodeAffinity: createNodeAffinityDraft(), }; -export type BenchmarkFormField = Exclude; +export type BenchmarkFormField = Exclude; export type BenchmarkFormErrors = Partial>; const maxInt32 = 2_147_483_647; @@ -79,6 +83,8 @@ export function toCreateBenchmarkRunRequest( if (Object.keys(validateBenchmarkForm(values)).length > 0) { return undefined; } + const affinity = prepareNodeAffinityDraft(values.nodeAffinity); + if (!affinity.ok) return undefined; const database = values.database.trim(); return { @@ -89,5 +95,6 @@ export function toCreateBenchmarkRunRequest( threads: Number(values.threads), scale: values.initialize ? Number(values.scale) : 1, initialize: values.initialize, + ...(affinity.nodeAffinity ? { nodeAffinity: affinity.nodeAffinity } : {}), }; } diff --git a/src/benchmarkNodeAffinity.ts b/src/benchmarkNodeAffinity.ts new file mode 100644 index 0000000..940eaea --- /dev/null +++ b/src/benchmarkNodeAffinity.ts @@ -0,0 +1,199 @@ +export type NodeSelectorOperator = 'In' | 'NotIn' | 'Exists' | 'DoesNotExist' | 'Gt' | 'Lt'; + +export type NodeSelectorRequirement = { + key: string; + operator: NodeSelectorOperator; + values?: string[]; +}; + +export type NodeSelectorTerm = { + matchExpressions?: NodeSelectorRequirement[]; + matchFields?: NodeSelectorRequirement[]; +}; + +export type NodeAffinity = { + requiredDuringSchedulingIgnoredDuringExecution?: { nodeSelectorTerms: NodeSelectorTerm[] }; + preferredDuringSchedulingIgnoredDuringExecution?: Array<{ weight: number; preference: NodeSelectorTerm }>; +}; + +// The initial editor works with labels. API snapshots may also contain matchFields. +export type NodeAffinityConditionDraft = { + key: string; + operator: NodeSelectorOperator; + values: string[]; +}; +export type NodeAffinityRequiredDraft = { matchExpressions: NodeAffinityConditionDraft[] }; +export type NodeAffinityPreferredDraft = NodeAffinityRequiredDraft & { weight: string }; +export type NodeAffinityDraft = { + enabled: boolean; + required: NodeAffinityRequiredDraft[]; + preferred: NodeAffinityPreferredDraft[]; +}; +export type NodeAffinityErrors = Record; +export type PreparedNodeAffinityDraft = + | { ok: true; nodeAffinity?: NodeAffinity } + | { ok: false; errors: NodeAffinityErrors }; + +// Return fresh arrays for each form; drafts must not share mutable initial state. +export function createNodeAffinityDraft(): NodeAffinityDraft { + return { enabled: false, required: [], preferred: [] }; +} + +const operators: readonly string[] = ['In', 'NotIn', 'Exists', 'DoesNotExist', 'Gt', 'Lt']; +const labelName = /^[A-Za-z0-9](?:[-_.A-Za-z0-9]*[A-Za-z0-9])?$/; +const dnsSubdomain = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?(?:\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?)*$/; + +function validLabelValue(value: string): boolean { + return value === value.trim() && value.length <= 63 && (value === '' || labelName.test(value)); +} + +function validDnsSubdomain(value: string): boolean { + return value === value.trim() && value.length <= 253 && dnsSubdomain.test(value); +} + +function validLabelKey(key: string): boolean { + const parts = key.split('/'); + const name = parts[parts.length - 1]; + return key === key.trim() && parts.length <= 2 && name.length > 0 && name.length <= 63 && labelName.test(name) && + (parts.length === 1 || validDnsSubdomain(parts[0])); +} + +function objectWithFields( + value: unknown, fields: readonly string[], path: string, errors: NodeAffinityErrors +): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + errors[path] = 'Must be an object.'; + return false; + } + for (const key of Object.keys(value)) { + if (!fields.includes(key)) errors[`${path}.${key}`] = 'Unsupported field.'; + } + return true; +} + +function validateRequirement(value: unknown, path: string, field: boolean, errors: NodeAffinityErrors): void { + if (!objectWithFields(value, ['key', 'operator', 'values'], path, errors)) return; + const { key, operator, values } = value; + if (typeof key !== 'string' || (field ? key !== 'metadata.name' : !validLabelKey(key))) { + errors[`${path}.key`] = field ? 'Only metadata.name is supported.' : 'Enter a valid Kubernetes label key.'; + } + if (typeof operator !== 'string' || !(field ? ['In', 'NotIn'] : operators).includes(operator)) { + errors[`${path}.operator`] = 'Unsupported selector operator.'; + return; + } + if (values !== undefined && (!Array.isArray(values) || !values.every(item => typeof item === 'string'))) { + errors[`${path}.values`] = 'Values must be a list of strings.'; + return; + } + const items = (values ?? []) as string[]; + if (field) { + if (items.length !== 1 || !validDnsSubdomain(items[0])) { + errors[`${path}.values`] = 'Enter exactly one valid node name.'; + } + return; + } + items.forEach((item, index) => { + if (!validLabelValue(item)) errors[`${path}.values[${index}]`] = 'Enter a valid Kubernetes label value.'; + }); + if ((operator === 'In' || operator === 'NotIn') && items.length === 0) { + errors[`${path}.values`] = 'Enter at least one value.'; + } else if ((operator === 'Exists' || operator === 'DoesNotExist') && items.length !== 0) { + errors[`${path}.values`] = 'This operator does not accept values.'; + } else if (operator === 'Gt' || operator === 'Lt') { + // Label syntax already excludes signed values. BigInt avoids rounding int64 thresholds. + if (items.length !== 1 || !/^\d+$/.test(items[0]) || BigInt(items[0]) > 9_223_372_036_854_775_807n) { + errors[`${path}.values`] = 'Enter exactly one integer threshold within the signed 64-bit range.'; + } + } +} + +function validateTerm(value: unknown, path: string, errors: NodeAffinityErrors): void { + if (!objectWithFields(value, ['matchExpressions', 'matchFields'], path, errors)) return; + let conditions = 0; + for (const name of ['matchExpressions', 'matchFields'] as const) { + const requirements = value[name]; + if (requirements === undefined) continue; + if (!Array.isArray(requirements)) { + errors[`${path}.${name}`] = 'Must be a list of conditions.'; + continue; + } + conditions += requirements.length; + requirements.forEach((requirement, index) => + validateRequirement(requirement, `${path}.${name}[${index}]`, name === 'matchFields', errors)); + } + if (conditions === 0) errors[path] = 'Add at least one condition to this group.'; +} + +// Validate untrusted status JSON before it is used by a renderer. The backend +// remains authoritative for submissions; this does not test node availability. +export function validateNodeAffinity(value: unknown): NodeAffinityErrors { + const errors: NodeAffinityErrors = {}; + const requiredName = 'requiredDuringSchedulingIgnoredDuringExecution'; + const preferredName = 'preferredDuringSchedulingIgnoredDuringExecution'; + if (!objectWithFields(value, [requiredName, preferredName], 'nodeAffinity', errors)) return errors; + const required = value[requiredName]; + const requiredPath = `nodeAffinity.${requiredName}`; + if (required !== undefined && objectWithFields(required, ['nodeSelectorTerms'], requiredPath, errors)) { + const terms = required.nodeSelectorTerms; + if (!Array.isArray(terms) || terms.length === 0) { + errors[`${requiredPath}.nodeSelectorTerms`] = 'Add at least one required group.'; + } else { + terms.forEach((term, index) => validateTerm(term, `${requiredPath}.nodeSelectorTerms[${index}]`, errors)); + } + } + const preferred = value[preferredName]; + const preferredPath = `nodeAffinity.${preferredName}`; + if (preferred !== undefined) { + if (!Array.isArray(preferred)) { + errors[preferredPath] = 'Must be a list of preferred groups.'; + } else { + preferred.forEach((term, index) => { + const path = `${preferredPath}[${index}]`; + if (!objectWithFields(term, ['weight', 'preference'], path, errors)) return; + if (typeof term.weight !== 'number' || !Number.isInteger(term.weight) || term.weight < 1 || term.weight > 100) { + errors[`${path}.weight`] = 'Weight must be a whole number from 1 to 100.'; + } + validateTerm(term.preference, `${path}.preference`, errors); + }); + } + } + return errors; +} + +export function isNodeAffinity(value: unknown): value is NodeAffinity { + return Object.keys(validateNodeAffinity(value)).length === 0; +} + +export function prepareNodeAffinityDraft(draft?: NodeAffinityDraft): PreparedNodeAffinityDraft { + // Preserve disabled drafts in the form, but never send their rules. + if (!draft?.enabled) return { ok: true }; + if (draft.required.length === 0 && draft.preferred.length === 0) { + return { ok: false, errors: { nodeAffinity: 'Add at least one required or preferred group.' } }; + } + const copyTerm = (group: NodeAffinityRequiredDraft): NodeSelectorTerm => ({ + matchExpressions: group.matchExpressions.map(condition => ({ + key: condition.key.trim(), operator: condition.operator, values: [...condition.values], + })), + }); + const errors: NodeAffinityErrors = {}; + const affinity: NodeAffinity = {}; + if (draft.required.length > 0) { + affinity.requiredDuringSchedulingIgnoredDuringExecution = { nodeSelectorTerms: draft.required.map(copyTerm) }; + } + if (draft.preferred.length > 0) { + affinity.preferredDuringSchedulingIgnoredDuringExecution = draft.preferred.map((group, index) => { + if (!/^\d+$/.test(group.weight.trim())) { + errors[`preferred[${index}].weight`] = 'Weight must be a whole number from 1 to 100.'; + } + return { weight: Number(group.weight), preference: copyTerm(group) }; + }); + } + for (const [path, message] of Object.entries(validateNodeAffinity(affinity))) { + const draftPath = path + .replace('nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms', 'required') + .replace('nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution', 'preferred') + .replace('.preference', ''); + errors[draftPath] = message; + } + return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, nodeAffinity: affinity }; +} diff --git a/tests/benchmarkApi.test.mjs b/tests/benchmarkApi.test.mjs index ed5f63a..fb2b5a1 100644 --- a/tests/benchmarkApi.test.mjs +++ b/tests/benchmarkApi.test.mjs @@ -90,3 +90,24 @@ test('accepted response succeeds and disconnects the caller abort listener', asy controller.abort(); assert.equal(signal.aborted, false); }); + +test('POST forwards native affinity without changing the acceptance contract', async () => { + const nodeAffinity = { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ + matchExpressions: [{ key: 'workload', operator: 'In', values: ['benchmark'] }], + }] }, + preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 50, preference: { + matchExpressions: [{ key: 'disk', operator: 'In', values: ['ssd'] }], + } }], + }; + for (const payload of [request, { ...request, nodeAffinity }]) { + const before = structuredClone(payload); + assert.deepEqual(await createBenchmarkRun(async (path, init) => { + assert.equal(path, '/api/runs'); + assert.equal(init.method, 'POST'); + assert.deepEqual(JSON.parse(init.body), before); + return new Response('{"id":"run-1","status":"running"}', { status: 202 }); + }, payload), { id: 'run-1', status: 'running' }); + assert.deepEqual(payload, before); + } +}); diff --git a/tests/benchmarkForm.test.mjs b/tests/benchmarkForm.test.mjs index 91eb347..a5e1d2e 100644 --- a/tests/benchmarkForm.test.mjs +++ b/tests/benchmarkForm.test.mjs @@ -61,3 +61,34 @@ test('enabling initialization again requires a valid scale and uses its value', assert.equal(toCreateBenchmarkRunRequest({ ...values, initialize: true }, target), undefined); assert.equal(toCreateBenchmarkRunRequest({ ...values, initialize: true, scale: '10' }, target).scale, 10); }); + +test('request mapping omits disabled affinity and includes validated enabled rules', () => { + assert.equal(initialBenchmarkFormValues.nodeAffinity.enabled, false); + const nodeAffinity = { + enabled: false, required: [{ matchExpressions: [{ key: 'workload', operator: 'In', values: ['benchmark'] }] }], + preferred: [{ weight: '50', matchExpressions: [{ key: 'disk', operator: 'In', values: ['ssd', 'nvme'] }] }], + }; + const values = { ...validValues, nodeAffinity }; + assert.equal('nodeAffinity' in toCreateBenchmarkRunRequest(values, target), false); + assert.equal('nodeAffinity' in toCreateBenchmarkRunRequest({ ...validValues, nodeAffinity: undefined }, target), false); + const enabled = { ...values, nodeAffinity: { ...nodeAffinity, enabled: true } }; + const request = toCreateBenchmarkRunRequest(enabled, target); + assert.deepEqual(request.nodeAffinity, { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: nodeAffinity.required }, + preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 50, preference: { + matchExpressions: nodeAffinity.preferred[0].matchExpressions, + } }], + }); + assert.equal(toCreateBenchmarkRunRequest({ ...enabled, clients: '0' }, target), undefined); +}); + +test('invalid affinity blocks request mapping without polluting scalar field errors', () => { + const values = { ...validValues, nodeAffinity: { + enabled: true, required: [], preferred: [{ weight: '101', matchExpressions: [ + { key: 'disk', operator: 'In', values: ['ssd'] }, + ] }], + } }; + assert.deepEqual(validateBenchmarkForm(values), {}); + assert.equal(toCreateBenchmarkRunRequest(values, target), undefined); + assert.ok(toCreateBenchmarkRunRequest({ ...values, nodeAffinity: { ...values.nodeAffinity, enabled: false } }, target)); +}); diff --git a/tests/benchmarkNodeAffinity.test.mjs b/tests/benchmarkNodeAffinity.test.mjs new file mode 100644 index 0000000..adeb76c --- /dev/null +++ b/tests/benchmarkNodeAffinity.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createNodeAffinityDraft, isNodeAffinity, prepareNodeAffinityDraft, validateNodeAffinity, +} from '../dist/form-test/benchmarkNodeAffinity.js'; + +const condition = (key = 'workload', operator = 'In', values = ['benchmark']) => ({ key, operator, values }); +const draft = () => ({ + enabled: true, + required: [ + { matchExpressions: [condition(), condition('generation', 'Gt', ['3'])] }, + { matchExpressions: [condition('example.com/pool', 'In', ['batch', 'compute'])] }, + ], + preferred: [{ weight: '75', matchExpressions: [condition('disk', 'In', ['ssd'])] }], +}); + +test('disabled drafts omit affinity without destroying entered rules; initial drafts are independent', () => { + const input = draft(); + input.enabled = false; + input.preferred[0].weight = 'invalid'; + const before = structuredClone(input); + assert.deepEqual(prepareNodeAffinityDraft(input), { ok: true }); + assert.deepEqual(input, before); + assert.deepEqual(prepareNodeAffinityDraft(), { ok: true }); + const first = createNodeAffinityDraft(); + first.required.push({ matchExpressions: [condition()] }); + assert.deepEqual(createNodeAffinityDraft(), { enabled: false, required: [], preferred: [] }); +}); + +test('conversion preserves AND conditions, OR groups, weighted preferences, and multiple values', () => { + const input = draft(); + const before = structuredClone(input); + const prepared = prepareNodeAffinityDraft(input); + assert.deepEqual(prepared, { ok: true, nodeAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: before.required }, + preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 75, preference: { + matchExpressions: [condition('disk', 'In', ['ssd'])], + } }], + } }); + assert.deepEqual(input, before); + prepared.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0].values[0] = 'changed'; + prepared.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution[0].preference.matchExpressions[0].key = 'changed'; + assert.deepEqual(input, before); +}); + +test('required-only and preferred-only drafts omit the unused native section', () => { + for (const kind of ['required', 'preferred']) { + const input = draft(); + input[kind === 'required' ? 'preferred' : 'required'] = []; + const result = prepareNodeAffinityDraft(input); + assert.equal(result.ok, true); + assert.deepEqual(Object.keys(result.nodeAffinity), [kind === 'required' + ? 'requiredDuringSchedulingIgnoredDuringExecution' : 'preferredDuringSchedulingIgnoredDuringExecution']); + } +}); + +test('invalid enabled drafts provide paths and never return a partial payload', () => { + const invalid = [ + [{ enabled: true, required: [], preferred: [] }, 'nodeAffinity'], + [{ enabled: true, required: [{ matchExpressions: [] }], preferred: [] }, 'required[0]'], + [{ enabled: true, required: [{ matchExpressions: [condition('bad/key/extra')] }], preferred: [] }, 'required[0].matchExpressions[0].key'], + [{ enabled: true, required: [], preferred: [{ weight: '75', matchExpressions: [] }] }, 'preferred[0]'], + ]; + for (const weight of ['', '0', '101', '1.5', 'abc', '1e2', '0x10', 'Infinity']) { + const input = draft(); + input.preferred[0].weight = weight; + invalid.push([input, 'preferred[0].weight']); + } + for (const [input, path] of invalid) { + const result = prepareNodeAffinityDraft(input); + assert.equal(result.ok, false, JSON.stringify(input)); + assert.ok(result.errors[path], path); + assert.equal('nodeAffinity' in result, false); + } +}); + +const native = requirement => ({ + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [requirement] }] }, +}); + +test('all label operators follow cardinality, label syntax, and int64 threshold rules', () => { + const valid = [ + condition(), condition('example.com/Pool_Name', 'NotIn', ['ssd', '']), + condition('gpu', 'Exists', []), { key: 'gpu', operator: 'DoesNotExist' }, + condition('generation', 'Gt', ['9223372036854775807']), + condition('generation', 'Lt', ['0']), condition('generation', 'Gt', ['0003']), + condition('a'.repeat(63), 'In', ['v'.repeat(63)]), + ]; + const invalid = [ + condition('', 'In', ['x']), condition('Bad.Example/key'), condition('bad key'), + condition('example.com/'), condition('a'.repeat(64)), condition('workload', 'Invalid'), + condition('workload', 'In', []), { key: 'workload', operator: 'NotIn' }, + condition('gpu', 'Exists', ['yes']), condition('gpu', 'DoesNotExist', ['yes']), + condition('generation', 'Gt', []), condition('generation', 'Lt', ['1', '2']), + ...['-1', '+1', '1.5', '1e2', '', '9223372036854775808'].map(value => condition('generation', 'Gt', [value])), + condition('disk', 'In', ['bad value']), condition('disk', 'In', ['v'.repeat(64)]), + condition('disk', 'In', ['ssd\n']), condition('workload\n'), + ]; + for (const item of valid) assert.deepEqual(validateNodeAffinity(native(item)), {}, JSON.stringify(item)); + for (const item of invalid) assert.equal(isNodeAffinity(native(item)), false, JSON.stringify(item)); +}); + +test('native snapshots accept empty affinity and field selectors in required and preferred groups', () => { + for (const affinity of [{}, { preferredDuringSchedulingIgnoredDuringExecution: [] }]) { + assert.equal(isNodeAffinity(affinity), true); + } + const term = { matchFields: [{ key: 'metadata.name', operator: 'NotIn', values: ['node-1.example'] }] }; + assert.equal(isNodeAffinity({ + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [term] }, + preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 100, preference: term }], + }), true); + for (const requirement of [ + { key: 'metadata.namespace', operator: 'In', values: ['node-1'] }, + { key: 'metadata.name', operator: 'Exists' }, + ...[[], ['node-1', 'node-2'], ['BAD'], ['node name'], ['node-1\n'], ['']] + .map(values => ({ key: 'metadata.name', operator: 'In', values })), + ]) { + assert.equal(isNodeAffinity({ requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [{ matchFields: [requirement] }], + } }), false, JSON.stringify(requirement)); + } +}); + +test('untrusted snapshots reject malformed nested structures, types, and unknown fields', () => { + const required = value => ({ requiredDuringSchedulingIgnoredDuringExecution: value }); + const preferred = value => ({ preferredDuringSchedulingIgnoredDuringExecution: value }); + const cases = [null, [], 'affinity', { nodeSelector: {} }, required(null), required({}), + required({ nodeSelectorTerms: [] }), required({ nodeSelectorTerms: {} }), + required({ nodeSelectorTerms: [null] }), required({ nodeSelectorTerms: [{}] }), + required({ nodeSelectorTerms: [{ matchExpressions: {} }] }), + required({ nodeSelectorTerms: [{ matchFields: null }] }), + native({ ...condition(), values: 'benchmark' }), native({ ...condition(), values: [1] }), + native({ ...condition(), extra: true }), native({ ...condition(), key: 123 }), + preferred(null), preferred({}), preferred([null]), preferred([{ weight: 50 }]), + ...[0, 101, 1.5, '50', NaN, Infinity].map(weight => preferred([{ weight, preference: { matchExpressions: [condition()] } }])), + preferred([{ weight: 50, preference: { matchExpressions: [condition()], extra: true } }]), + ]; + for (const value of cases) assert.equal(isNodeAffinity(value), false, JSON.stringify(value)); +}); diff --git a/tests/benchmarkNodeAffinityForm.test.mjs b/tests/benchmarkNodeAffinityForm.test.mjs new file mode 100644 index 0000000..3a12538 --- /dev/null +++ b/tests/benchmarkNodeAffinityForm.test.mjs @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import React from 'react'; +import renderer from 'react-test-renderer'; +import { JSDOM } from 'jsdom'; +import { toCreateBenchmarkRunRequest } from '../dist/form-test/benchmarkFormValidation.js'; + +// Install the DOM before importing MUI/Emotion so refs use their client behavior. +const dom = new JSDOM(''); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +const { Accordion, Button, Switch, TextField, Chip } = await import('@mui/material'); +const { CacheProvider } = await import('@emotion/react'); +const { default: createCache } = await import('@emotion/cache'); +const { BenchmarkForm } = await import('../dist/form-test/BenchmarkForm.js'); +const { BenchmarkNodeAffinityForm } = await import('../dist/form-test/BenchmarkNodeAffinityForm.js'); +test.after(() => { dom.window.close(); delete globalThis.window; delete globalThis.document; }); + +const { act } = renderer; +const target = { k8sCluster: 'main', namespace: 'default', instance: 'pg-1' }; +// MUI's Collapse and Select use DOM refs. Return detached DOM elements; +// tests drive the public component callbacks, not simulated browser layout. +const createNodeMock = element => document.createElement(element.type); + +function setup(t, isSubmitting = false) { + const submissions = []; + const cache = createCache({ key: 'affinity-test', container: document.head }); + const element = pending => React.createElement(CacheProvider, { value: cache }, React.createElement(BenchmarkForm, { + target, isSubmitting: pending, onSubmit: values => submissions.push(toCreateBenchmarkRunRequest(values, target)), + })); + let view; + act(() => { view = renderer.create(element(isSubmitting), { createNodeMock }); }); + t.after(() => { act(() => view.unmount()); cache.sheet.flush(); }); + return { view, submissions, setSubmitting: pending => act(() => view.update(element(pending))) }; +} +const editor = view => view.root.findByType(BenchmarkNodeAffinityForm); +const button = (view, label) => view.root.findAllByType(Button).find(item => item.props.children === label); +const fields = (view, label) => view.root.findAllByType(TextField).filter(item => item.props.label === label); +const click = (view, label) => act(() => button(view, label).props.onClick()); +const change = (field, value) => act(() => field.props.onChange({ target: { value } })); +const enable = (view, checked) => act(() => view.root.findByType(Switch).props.onChange({ target: { checked } })); +const collapse = (view, expanded) => act(() => view.root.findByType(Accordion).props.onChange({}, expanded)); +const submit = view => act(() => view.root.findByType('form').props.onSubmit({ preventDefault() {} })); +const remove = (view, label) => act(() => view.root.findAllByType(Button) + .find(item => item.props['aria-label'] === label).props.onClick()); + +test('placement is off and collapsed by default; ordinary submissions omit affinity', t => { + const { view, submissions } = setup(t); + assert.equal(view.root.findByType(Accordion).props.expanded, false); + assert.equal(view.root.findByType(Switch).props.checked, false); + assert.equal(button(view, 'Start benchmark').props.disabled, false); + assert.equal(fields(view, 'Label key').length, 0); + submit(view); + assert.equal(submissions.length, 1); + assert.equal('nodeAffinity' in submissions[0], false); +}); + +test('editing required AND/OR groups, multiple values, and weighted preferences reaches submission', t => { + const { view, submissions } = setup(t); + collapse(view, true); + enable(view, true); + assert.equal(button(view, 'Start benchmark').props.disabled, true); + submit(view); + assert.equal(submissions.length, 0); + click(view, 'Add required group'); + change(fields(view, 'Label key')[0], 'workload'); + change(fields(view, 'Value 1')[0], 'benchmark'); + click(view, 'Add condition'); + change(fields(view, 'Label key')[1], 'zone'); + change(fields(view, 'Value 1')[1], 'east'); + // Add a second value to the second condition. + act(() => view.root.findAllByType(Button).filter(item => item.props.children === 'Add value')[1].props.onClick()); + change(fields(view, 'Value 2')[0], 'west'); + click(view, 'Add required group'); + change(fields(view, 'Label key')[2], 'pool'); + change(fields(view, 'Value 1')[2], 'batch'); + click(view, 'Add preferred group'); + change(fields(view, 'Label key')[3], 'disk'); + change(fields(view, 'Value 1')[3], 'ssd'); + change(fields(view, 'Weight')[0], '75'); + assert.equal(button(view, 'Start benchmark').props.disabled, false); + submit(view); + assert.deepEqual(submissions[0].nodeAffinity, { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [ + { matchExpressions: [ + { key: 'workload', operator: 'In', values: ['benchmark'] }, + { key: 'zone', operator: 'In', values: ['east', 'west'] }, + ] }, + { matchExpressions: [{ key: 'pool', operator: 'In', values: ['batch'] }] }, + ] }, + preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 75, preference: { + matchExpressions: [{ key: 'disk', operator: 'In', values: ['ssd'] }], + } }], + }); +}); + +test('collapse and the enable switch preserve drafts; invalid rules block only enabled submissions', t => { + const { view, submissions } = setup(t); + collapse(view, true); + enable(view, true); + click(view, 'Add preferred group'); + change(fields(view, 'Label key')[0], 'disk'); + change(fields(view, 'Value 1')[0], 'ssd'); + change(fields(view, 'Weight')[0], '101'); + assert.equal(fields(view, 'Weight')[0].props.error, true); + assert.ok(editor(view).props.errors['preferred[0].weight']); + const draft = structuredClone(editor(view).props.draft); + collapse(view, false); + assert.deepEqual(editor(view).props.draft, draft); + assert.equal(button(view, 'Start benchmark').props.disabled, true); + submit(view); + assert.equal(submissions.length, 0); + enable(view, false); + assert.deepEqual(editor(view).props.draft.preferred, draft.preferred); + assert.equal(button(view, 'Start benchmark').props.disabled, false); + submit(view); + assert.equal('nodeAffinity' in submissions[0], false); + enable(view, true); + collapse(view, true); + assert.equal(fields(view, 'Weight')[0].props.value, '101'); + assert.equal(button(view, 'Start benchmark').props.disabled, true); +}); + +test('operator changes clear hidden values and show a single numeric threshold', t => { + const { view, submissions } = setup(t); + enable(view, true); + click(view, 'Add required group'); + change(fields(view, 'Label key')[0], 'generation'); + change(fields(view, 'Value 1')[0], 'ssd'); + click(view, 'Add value'); + change(fields(view, 'Value 2')[0], 'nvme'); + change(fields(view, 'Operator')[0], 'Exists'); + assert.deepEqual(editor(view).props.draft.required[0].matchExpressions[0].values, []); + assert.equal(fields(view, 'Value 1').length, 0); + assert.equal(button(view, 'Add value'), undefined); + assert.equal(button(view, 'Start benchmark').props.disabled, false); + change(fields(view, 'Operator')[0], 'Gt'); + assert.equal(fields(view, 'Integer threshold').length, 1); + assert.equal(button(view, 'Start benchmark').props.disabled, true); + change(fields(view, 'Integer threshold')[0], '9223372036854775807'); + assert.equal(button(view, 'Start benchmark').props.disabled, false); + submit(view); + assert.deepEqual(submissions[0].nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms[0].matchExpressions[0], + { key: 'generation', operator: 'Gt', values: ['9223372036854775807'] }); + change(fields(view, 'Operator')[0], 'DoesNotExist'); + assert.deepEqual(editor(view).props.draft.required[0].matchExpressions[0].values, []); +}); + +test('removing values, conditions, and groups exposes errors instead of silently dropping rules', t => { + const { view } = setup(t); + enable(view, true); + click(view, 'Add required group'); + change(fields(view, 'Label key')[0], 'workload'); + remove(view, 'Remove value 1 from required group 1 condition 1'); + assert.ok(editor(view).props.errors['required[0].matchExpressions[0].values']); + assert.equal(button(view, 'Start benchmark').props.disabled, true); + click(view, 'Add value'); + change(fields(view, 'Value 1')[0], 'benchmark'); + assert.equal(button(view, 'Start benchmark').props.disabled, false); + remove(view, 'Remove required group 1 condition 1'); + assert.ok(editor(view).props.errors['required[0]']); + remove(view, 'Remove required group 1'); + assert.ok(editor(view).props.errors.nodeAffinity); + assert.equal(button(view, 'Start benchmark').props.disabled, true); +}); + +test('pending submission disables placement controls and two forms own independent drafts', t => { + const first = setup(t); + enable(first.view, true); + click(first.view, 'Add required group'); + change(fields(first.view, 'Label key')[0], 'workload'); + first.setSubmitting(true); + assert.equal(first.view.root.findByType(Switch).props.disabled, true); + for (const field of fields(first.view, 'Label key')) assert.equal(field.props.disabled, true); + assert.equal(button(first.view, 'Add required group').props.disabled, true); + assert.equal(button(first.view, 'Starting…').props.disabled, true); + for (const item of first.view.root.findAllByType(Button)) { + if (item.props.children !== 'Starting…') assert.equal(item.props.type, 'button'); + } + const second = setup(t); + assert.deepEqual(editor(second.view).props.draft, { enabled: false, required: [], preferred: [] }); +}); + +test('collapsed placement errors remain discoverable', t => { + const { view } = setup(t); + enable(view, true); + assert.equal(view.root.findByType(Accordion).props.expanded, false); + assert.ok(view.root.findAllByType(Chip).some(item => item.props.label === '1 placement error')); + const summary = view.root.findAllByType('button').find(item => item.props['aria-expanded'] === false); + const controlledId = summary.props['aria-controls']; + assert.ok(controlledId); + const regions = view.root.findAll(item => typeof item.type === 'string' && item.props.id === controlledId); + assert.equal(regions.length, 1, 'The accordion control must identify one region, not duplicate IDs'); + assert.equal(regions[0].props.role, 'region'); + assert.equal(regions[0].props['aria-labelledby'], summary.props.id); +}); + +test('removing a preferred group keeps the remaining group’s weight and conditions', t => { + const { view, submissions } = setup(t); + enable(view, true); + click(view, 'Add preferred group'); + change(fields(view, 'Label key')[0], 'disk'); + change(fields(view, 'Value 1')[0], 'ssd'); + change(fields(view, 'Weight')[0], '25'); + click(view, 'Add preferred group'); + change(fields(view, 'Label key')[1], 'pool'); + change(fields(view, 'Value 1')[1], 'batch'); + change(fields(view, 'Weight')[1], '100'); + remove(view, 'Remove preferred group 1'); + assert.equal(fields(view, 'Label key')[0].props.value, 'pool'); + assert.equal(fields(view, 'Weight')[0].props.value, '100'); + submit(view); + assert.deepEqual(submissions[0].nodeAffinity, { preferredDuringSchedulingIgnoredDuringExecution: [{ + weight: 100, preference: { matchExpressions: [{ key: 'pool', operator: 'In', values: ['batch'] }] }, + }] }); +}); diff --git a/tests/benchmarkRunResult.test.mjs b/tests/benchmarkRunResult.test.mjs index 630a6ea..6816409 100644 --- a/tests/benchmarkRunResult.test.mjs +++ b/tests/benchmarkRunResult.test.mjs @@ -48,3 +48,51 @@ test('retrieval failure preserves last known run status and offers retry', () => assert.match(render(null, feedback), /Retry status check/); assert.match(render(null), /Checking status/); }); + +test('saved affinity displays required alternatives, AND conditions, weights, and native fields for all statuses', () => { + const nodeAffinity = { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [ + { matchExpressions: [ + { key: 'workload', operator: 'In', values: ['benchmark'] }, + { key: 'generation', operator: 'Gt', values: ['3'] }, + ] }, + { matchFields: [{ key: 'metadata.name', operator: 'NotIn', values: ['node-2'] }] }, + ] }, + preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 75, preference: { + matchExpressions: [{ key: 'disk', operator: 'In', values: ['ssd', 'nvme'] }], + matchFields: [{ key: 'metadata.name', operator: 'In', values: ['node-1'] }], + } }], + }; + for (const status of ['running', 'succeeded', 'failed']) { + const html = render({ ...base, status, nodeAffinity, ...(status === 'failed' ? { error: 'failed' } : {}) }); + assert.match(html, /Configured node affinity/); + assert.match(html, /Required group 1 \(AND\)/); + assert.match(html, /OR — Required group 2/); + assert.match(html, /Label: workload In/); + assert.match(html, /Label: generation Gt/); + assert.match(html, /Field: metadata.name NotIn/); + assert.match(html, /Preferred group 1 — weight 75/); + assert.match(html, /Field: metadata.name In/); + assert.match(html, /ssd/); + assert.match(html, /nvme/); + assert.match(html, /not the node ultimately selected/); + } +}); + +test('empty affinity is explicit; older responses without affinity do not imply no rules', () => { + assert.match(render({ ...base, nodeAffinity: {} }), /No node affinity configured/); + assert.doesNotMatch(render(base), /Configured node affinity|No node affinity configured/); + assert.doesNotMatch(render(null), /Configured node affinity/); +}); + +test('affinity strings are escaped as text, including field values and empty label values', () => { + const html = render({ ...base, nodeAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [ + { key: '', operator: 'In', values: ['', ''] }, + ], matchFields: [{ key: 'metadata.name', operator: 'In', values: [''] }] }] }, + } }); + assert.doesNotMatch(html, /