diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b61679c..82a8c4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: cache: npm - run: npm ci - run: npm run test:form + - run: npm run test:components - run: npm run test:submission - run: npm run test:status - run: npm run test:polling diff --git a/README.md b/README.md index c945945..481015f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,48 @@ The frontend is currently available as a PostgreSQL cluster detail tab. It is built with MUI and themed from the host through `@openeverest/plugin-theme`. Other database technologies can be added later through a driver-based design. +### Runner placement + +Administrators can pin every runner Job to specific nodes, such as a tainted, +dedicated benchmark node pool, with `runner.nodeSelector` and `runner.tolerations` +in Helm: + +```yaml +runner: + nodeSelector: + node-role/benchmark: "true" + tolerations: + - key: dedicated + operator: Equal + value: benchmark + effect: NoSchedule +``` + +Invalid values stop the backend at startup. Users can additionally require node +labels for a single run under **Job placement** in the UI. Those rules are sent as +`nodeAffinity` and apply on top of the deployment's node selector, so a node must +satisfy both. + +### Scheduling failures + +While waiting for a Job, the coordinator checks the runner Pod's scheduling +condition. If the same Pod is continuously observed as `PodScheduled=False` with +reason `Unschedulable` for `runner.schedulingGracePeriod` (default `2m`, or +`BENCHMARK_SCHEDULING_GRACE_PERIOD`), the run fails and its Job and temporary +credential Secret are cleaned up. Users see a generic "could not be scheduled" +message; the scheduler's explanation, which reveals node counts and taints, is +written to the backend log. + +The grace period starts at the first observation of scheduler rejection, not at +submission. Scheduling recovery, Pod replacement, or a gap in scheduling +observations resets it. It does not limit pgbench runtime or image downloading. +Choose a period that allows enough time for autoscalers to provision nodes, or +set `"0"` to disable early failure and skip the extra Pod lookup on each Job poll. + +The existing `BENCHMARK_EXECUTION_TIMEOUT` (default `5m`) still bounds the whole +run, and time spent waiting to be scheduled counts against it. A run that waits +long for a node therefore has less time left to benchmark. + ## Requirements For local development, install: diff --git a/backend/internal/api/node_affinity_test.go b/backend/internal/api/node_affinity_test.go new file mode 100644 index 0000000..ef4ab86 --- /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(), coordinator.Resources{}) + 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) || run.Request.NodeAffinity != nil { + 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(), coordinator.Resources{}) + 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 prepared snapshot even when the submitted + // affinity differs from it. + request.NodeAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Weight = 5 + } + _, err := store.Create("run-1", request, coordinator.Resources{}, 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(), coordinator.Resources{}) + 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 9daaaaa..6eae552 100644 --- a/backend/internal/api/runs.go +++ b/backend/internal/api/runs.go @@ -17,11 +17,17 @@ 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 const maxConcurrentRuns = 2 +const ( + unschedulableRunMessage = "benchmark runner could not be scheduled: no node satisfies the placement rules and resource requests" + unschedulableRunTimeoutMessage = "benchmark execution timed out before the runner could be scheduled: no node satisfies the placement rules and resource requests" +) + const ( RunStatusRunning RunStatus = "running" RunStatusSucceeded RunStatus = "succeeded" @@ -45,6 +51,7 @@ type CreateRunRequest struct { Scale int `json:"scale"` Initialize bool `json:"initialize"` Resources coordinator.Resources `json:"resources"` + NodeAffinity *corev1.NodeAffinity `json:"nodeAffinity,omitempty"` } // RunStatus describes the execution workflow. Running does not imply that @@ -56,6 +63,7 @@ type Run struct { ID string `json:"id"` Request CreateRunRequest `json:"request"` Resources coordinator.Resources `json:"resources"` + NodeAffinity corev1.NodeAffinity `json:"nodeAffinity"` Status RunStatus `json:"status"` CreatedAt time.Time `json:"createdAt"` CompletedAt *time.Time `json:"completedAt,omitempty"` @@ -70,6 +78,7 @@ type RunStatusResponse struct { Status RunStatus `json:"status"` Database string `json:"database,omitempty"` Resources coordinator.Resources `json:"resources"` + NodeAffinity corev1.NodeAffinity `json:"nodeAffinity"` CreatedAt time.Time `json:"createdAt"` CompletedAt *time.Time `json:"completedAt,omitempty"` JobName string `json:"jobName,omitempty"` @@ -99,11 +108,11 @@ 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 } - options := optionsFromRequest(request) resolved, err := coordinator.ResolveResources(a.resourceDefaults, options.Resources) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -160,7 +169,7 @@ 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, resolved) + run, err := a.store.Create(runID, request, resolved, options.NodeAffinity) if err != nil { <-a.runSlots a.runs.Done() @@ -192,6 +201,15 @@ func (a *API) executeRun(ctx context.Context, cancel context.CancelFunc, id stri status, message := RunStatusSucceeded, "" if err != nil { status, message = RunStatusFailed, "benchmark execution failed" + // Scheduler diagnostics reveal node counts and taints, so users get a + // generic message and the details stay in the backend log. + var schedulingErr *coordinator.SchedulingError + if errors.As(err, &schedulingErr) { + message = unschedulableRunMessage + if errors.Is(err, context.DeadlineExceeded) { + message = unschedulableRunTimeoutMessage + } + } log.Printf( "benchmark run %q failed: %v (job=%q, output_bytes=%d, output_truncated=%t)", id, err, result.JobName, len(result.Output), result.OutputTruncated, @@ -298,21 +316,32 @@ 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") } - return optionsFromRequest(request).Validate() + options := optionsFromRequest(request) + 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 } func optionsFromRequest(request CreateRunRequest) coordinator.Options { @@ -409,6 +438,7 @@ func (a *API) getRun(w http.ResponseWriter, r *http.Request) { Status: run.Status, Database: run.Request.Database, Resources: run.Resources, + 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 a0a0fbe..b4bcfde 100644 --- a/backend/internal/api/runs_test.go +++ b/backend/internal/api/runs_test.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "time" @@ -136,6 +137,11 @@ func TestCreateRunDecodesRequest(t *testing.T) { func TestCreateRunResourceOverridesAndStatusSnapshot(t *testing.T) { defaults := coordinator.Resources{CPURequest: "100m", CPULimit: "1", MemoryRequest: "128Mi", MemoryLimit: "512Mi"} + affinity := testNodeAffinity() + affinityJSON, err := json.Marshal(affinity) + if err != nil { + t.Fatal(err) + } for _, test := range []struct { name, resourceJSON string overrides coordinator.Resources @@ -160,7 +166,7 @@ func TestCreateRunResourceOverridesAndStatusSnapshot(t *testing.T) { api := NewAPI(func(context.Context, string, string, string, string) (*everest.Credentials, error) { return testCredentials(), nil }, func(context.Context, string, string, string, string) error { return nil }, runner, store, context.Background(), defaults) - body := strings.TrimSuffix(validCreateRunBody, "}") + test.resourceJSON + "}" + body := strings.TrimSuffix(validCreateRunBody, "}") + test.resourceJSON + `,"nodeAffinity":` + string(affinityJSON) + "}" r := httptest.NewRequest(http.MethodPost, "/api/runs", strings.NewReader(body)) r.Header.Set("Authorization", "Bearer test-token") r.Header.Set("Content-Type", "application/json") @@ -180,6 +186,9 @@ func TestCreateRunResourceOverridesAndStatusSnapshot(t *testing.T) { if options.Resources != test.overrides { t.Fatalf("runner overrides=%+v; want %+v", options.Resources, test.overrides) } + if !reflect.DeepEqual(options.NodeAffinity, affinity) { + t.Fatal("resource overrides discarded the submitted node affinity") + } case <-time.After(time.Second): t.Fatal("runner was not started") } @@ -218,6 +227,9 @@ func TestCreateRunResourceOverridesAndStatusSnapshot(t *testing.T) { if response.Status != wantStatus || response.Resources != test.resolved { t.Fatalf("status response=%+v; want status %s and resources %+v", response, wantStatus, test.resolved) } + if !reflect.DeepEqual(&response.NodeAffinity, affinity) { + t.Fatal("resource snapshot discarded the stored node affinity") + } }) } } @@ -538,10 +550,12 @@ func TestGetRun(t *testing.T) { store := NewRunStore() snapshot := coordinator.Resources{CPURequest: "250m", CPULimit: "1", MemoryRequest: "128Mi", MemoryLimit: "512Mi"} if test.exists { + affinity := testNodeAffinity() run, err := store.Create("run-123", CreateRunRequest{ - Target: Target{K8sCluster: "local", Namespace: "databases", Instance: "postgres-1"}, - Database: "app", - }, snapshot) + Target: Target{K8sCluster: "local", Namespace: "databases", Instance: "postgres-1"}, + Database: "app", + NodeAffinity: affinity, + }, snapshot, affinity) if err != nil { t.Fatal(err) } @@ -600,6 +614,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/scheduling_test.go b/backend/internal/api/scheduling_test.go new file mode 100644 index 0000000..c69adbc --- /dev/null +++ b/backend/internal/api/scheduling_test.go @@ -0,0 +1,173 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/openeverest/plugin-bench/backend/internal/coordinator" + "github.com/openeverest/plugin-bench/backend/internal/everest" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +func TestExecuteRunHidesSchedulingDiagnostics(t *testing.T) { + for _, scenario := range []string{"generic", "scheduling", "timeout with scheduling"} { + t.Run(scenario, func(t *testing.T) { + store := NewRunStore() + if _, err := store.Create("run", CreateRunRequest{Target: Target{Instance: "postgres"}}, coordinator.Resources{}, nil); err != nil { + t.Fatal(err) + } + var runErr error = errors.New("private backend failure: secret-password") + if scenario != "generic" { + runErr = errors.Join(runErr, fmt.Errorf("wrapped: %w", &coordinator.SchedulingError{ + PodName: "runner", Message: "no node matches secret-password", GracePeriod: time.Minute, + })) + } + if scenario == "timeout with scheduling" { + runErr = errors.Join(runErr, context.DeadlineExceeded) + } + api := NewAPI(nil, func(context.Context, string, string, string, string) error { return nil }, + testRunnerFunc(func(context.Context, coordinator.Connection, coordinator.Options) (coordinator.Result, error) { + return coordinator.Result{JobName: "job", Output: "secret-password"}, runErr + }), store, context.Background(), coordinator.Resources{}) + api.runSlots <- struct{}{} + api.runs.Add(1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + api.executeRun(ctx, cancel, "run", coordinator.Connection{Password: "secret-password"}, coordinator.DefaultOptions()) + r := httptest.NewRequest(http.MethodGet, "/api/runs/run", nil) + r.SetPathValue("id", "run") + r.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + api.getRun(w, r) + var response RunStatusResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if w.Code != http.StatusOK || response.Status != RunStatusFailed || response.Output != "[REDACTED]" { + t.Fatalf("unexpected status response: %s", w.Body.String()) + } + if strings.Contains(w.Body.String(), "secret-password") || strings.Contains(response.Error, "private backend failure") || + strings.Contains(response.Error, "no node matches") { + t.Fatal("status response exposed an unrelated error, a scheduler diagnostic, or a credential") + } + want := map[string]string{ + "generic": "benchmark execution failed", + "scheduling": unschedulableRunMessage, + "timeout with scheduling": unschedulableRunTimeoutMessage, + }[scenario] + if response.Error != want { + t.Fatalf("error = %q, want %q", response.Error, want) + } + if len(api.runSlots) != 0 || ctx.Err() == nil { + t.Fatal("execution did not release its slot and context") + } + api.runs.Wait() + }) + } +} + +func TestUnschedulableRunsReleaseCapacity(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + client := fake.NewSimpleClientset() + 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) + job.UID = types.UID("uid-" + job.Name) + return false, nil, nil + }) + client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + name, found := action.(ktesting.ListAction).GetListRestrictions().Labels.RequiresExactMatch(batchv1.JobNameLabel) + if !found { + t.Fatal("missing Job label selector") + } + controller := true + return true, &corev1.PodList{Items: []corev1.Pod{{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-pod", Namespace: "workloads", UID: types.UID("pod-" + name), + Labels: map[string]string{batchv1.JobNameLabel: name}, + OwnerReferences: []metav1.OwnerReference{{APIVersion: "batch/v1", Kind: "Job", Name: name, UID: types.UID("uid-" + name), Controller: &controller}}, + }, + Status: corev1.PodStatus{Conditions: []corev1.PodCondition{{Type: corev1.PodScheduled, + Status: corev1.ConditionFalse, Reason: corev1.PodReasonUnschedulable, Message: "no nodes match required affinity"}}}, + }}}, nil + }) + runner, err := coordinator.New(coordinator.Config{WorkloadNamespace: "workloads", RunnerImage: "runner:test", + ImagePullPolicy: "IfNotPresent", RunnerServiceAccount: "runner", ExecutionTimeout: 5 * time.Minute, + SchedulingGracePeriod: time.Minute}, client) + if err != nil { + t.Fatal(err) + } + store := NewRunStore() + lifecycleCtx, stop := context.WithCancel(context.Background()) + api := NewAPI(func(context.Context, string, string, string, string) (*everest.Credentials, error) { + return testCredentials(), nil + }, nil, runner, store, lifecycleCtx, coordinator.Resources{}) + defer func() { stop(); api.runs.Wait() }() + submit := func() *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/api/runs", strings.NewReader(validCreateRunBody)) + r.Header.Set("Authorization", "Bearer test-token") + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + api.createRun(w, r) + return w + } + var ids []string + for i := 0; i < maxConcurrentRuns; i++ { + w := submit() + if w.Code != http.StatusAccepted { + t.Fatalf("submission failed: %s", w.Body.String()) + } + var response struct { + ID string `json:"id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + ids = append(ids, response.ID) + } + synctest.Wait() + if w := submit(); w.Code != http.StatusTooManyRequests { + t.Fatalf("expected full capacity, got %d", w.Code) + } + time.Sleep(time.Minute) + synctest.Wait() + for _, id := range ids { + run, err := store.Get(id) + if err != nil || run.Status != RunStatusFailed || run.Error != unschedulableRunMessage { + t.Fatalf("unexpected failed run: %+v, %v", run, err) + } + } + if len(api.runSlots) != 0 { + t.Fatal("scheduling failures did not free run capacity") + } + if jobs, err := client.BatchV1().Jobs("workloads").List(context.Background(), metav1.ListOptions{}); err != nil || len(jobs.Items) != 0 { + t.Fatal("failed Jobs were not deleted") + } + if secrets, err := client.CoreV1().Secrets("workloads").List(context.Background(), metav1.ListOptions{}); err != nil || len(secrets.Items) != 0 { + t.Fatal("credential Secrets were not deleted") + } + if w := submit(); w.Code != http.StatusAccepted { + t.Fatalf("capacity was not reusable: %s", w.Body.String()) + } + // Finish the final test run before leaving the fake-clock bubble. + time.Sleep(time.Minute) + synctest.Wait() + api.runs.Wait() + }) +} diff --git a/backend/internal/api/store.go b/backend/internal/api/store.go index 3fcb349..f3d4404 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,10 @@ type RunStore struct { now func() time.Time } -// Create stores a new running record. -func (s *RunStore) Create(id string, request CreateRunRequest, resources coordinator.Resources) (Run, error) { +// Create stores the request settings and an independent prepared affinity snapshot. +// Affinity is retained only in Run.NodeAffinity, not in the stored request. +// A nil prepared affinity is retained as an empty object in status responses. +func (s *RunStore) Create(id string, request CreateRunRequest, resources coordinator.Resources, nodeAffinity *corev1.NodeAffinity) (Run, error) { if strings.TrimSpace(id) == "" { return Run{}, errors.New("run ID is required") } @@ -35,12 +38,16 @@ func (s *RunStore) Create(id string, request CreateRunRequest, resources coordin if _, exists := s.runs[id]; exists { return Run{}, ErrRunExists } + request.NodeAffinity = nil run := Run{ID: id, Request: request, Resources: resources, 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 +61,16 @@ 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.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 3aa416f..efca2aa 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" @@ -15,14 +16,14 @@ func TestRunStoreLifecycle(t *testing.T) { s := NewRunStore() request := CreateRunRequest{Database: "benchmark", Resources: coordinator.Resources{CPURequest: "500m"}} resolved := coordinator.Resources{CPURequest: "500m", CPULimit: "1", MemoryRequest: "128Mi", MemoryLimit: "512Mi"} - created, err := s.Create("run-1", request, resolved) + created, err := s.Create("run-1", request, resolved, nil) if err != nil || created.Status != RunStatusRunning || created.CreatedAt.IsZero() { t.Fatalf("Create = %+v, %v", created, err) } created.Request.Database = "changed" created.Request.Resources.CPURequest = "changed" created.Resources.CPURequest = "changed" - if _, err := s.Create("run-1", CreateRunRequest{}, coordinator.Resources{}); !errors.Is(err, ErrRunExists) { + if _, err := s.Create("run-1", CreateRunRequest{}, coordinator.Resources{}, nil); !errors.Is(err, ErrRunExists) { t.Fatalf("duplicate Create: %v", err) } message := "" @@ -53,7 +54,7 @@ func TestRunStoreLifecycle(t *testing.T) { func TestRunStoreRejectsInvalidOperations(t *testing.T) { s := NewRunStore() - if _, err := s.Create(" ", CreateRunRequest{}, coordinator.Resources{}); err == nil { + if _, err := s.Create(" ", CreateRunRequest{}, coordinator.Resources{}, nil); err == nil { t.Fatal("accepted empty ID") } if _, err := s.Get("missing"); !errors.Is(err, ErrRunNotFound) { @@ -62,7 +63,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{}, coordinator.Resources{}); err != nil { + if _, err := s.Create("run", CreateRunRequest{}, coordinator.Resources{}, nil); err != nil { t.Fatal(err) } for _, test := range []struct { @@ -81,9 +82,51 @@ func TestRunStoreRejectsInvalidOperations(t *testing.T) { } } +func TestRunStoreOwnsPreparedNodeAffinitySnapshot(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 + wantSnapshot := prepared.DeepCopy() + request := CreateRunRequest{Database: "benchmark", NodeAffinity: submitted} + created, err := store.Create("run-1", request, coordinator.Resources{}, prepared) + if err != nil { + t.Fatal(err) + } + if created.Request.NodeAffinity != nil || created.Request.Database != request.Database { + t.Fatalf("stored request must retain settings without duplicate affinity: %+v", created.Request) + } + if request.NodeAffinity != submitted { + t.Fatal("Create changed the caller's request affinity") + } + mutateTestNodeAffinity(submitted) + mutateTestNodeAffinity(prepared) + mutateTestNodeAffinity(&created.NodeAffinity) + got, err := store.Get("run-1") + if err != nil || got.Request.NodeAffinity != nil || !reflect.DeepEqual(&got.NodeAffinity, wantSnapshot) { + t.Fatalf("Create exposed its stored pointers: run=%+v err=%v", got, err) + } + 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 || completed.Request.NodeAffinity != nil || !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{}, coordinator.Resources{}); err != nil { + if _, err := s.Create("run", CreateRunRequest{}, coordinator.Resources{}, nil); err != nil { t.Fatal(err) } var wg sync.WaitGroup @@ -124,7 +167,7 @@ func TestRunStoreExpiresCompletedRuns(t *testing.T) { s := NewRunStore() s.now = func() time.Time { return now } - if _, err := s.Create("completed", CreateRunRequest{}, coordinator.Resources{}); err != nil { + if _, err := s.Create("completed", CreateRunRequest{}, coordinator.Resources{}, nil); err != nil { t.Fatal(err) } if err := s.Complete("completed", test.status, coordinator.Result{}, test.message); err != nil { @@ -135,7 +178,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{}, coordinator.Resources{}); err != nil { + if _, err := s.Create("completed", CreateRunRequest{}, coordinator.Resources{}, nil); err != nil { t.Fatalf("Create should prune the expired run before reusing its ID: %v", err) } }) @@ -147,11 +190,11 @@ func TestRunStoreRetainsRunningRunsPastRetention(t *testing.T) { s := NewRunStore() s.now = func() time.Time { return now } - if _, err := s.Create("running", CreateRunRequest{}, coordinator.Resources{}); err != nil { + if _, err := s.Create("running", CreateRunRequest{}, coordinator.Resources{}, nil); err != nil { t.Fatal(err) } now = now.Add(completedRunRetention + time.Second) - if _, err := s.Create("another", CreateRunRequest{}, coordinator.Resources{}); err != nil { + if _, err := s.Create("another", CreateRunRequest{}, coordinator.Resources{}, nil); err != nil { t.Fatal(err) } diff --git a/backend/internal/coordinator/coordinator.go b/backend/internal/coordinator/coordinator.go index ca494ae..40cc58b 100644 --- a/backend/internal/coordinator/coordinator.go +++ b/backend/internal/coordinator/coordinator.go @@ -6,6 +6,7 @@ import ( "strings" "time" + corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" ) @@ -20,6 +21,12 @@ type Config struct { RunnerServiceAccount string Resources Resources ExecutionTimeout time.Duration + // SchedulingGracePeriod limits continuously observed scheduler rejection; zero disables it. + SchedulingGracePeriod time.Duration + // NodeSelector and Tolerations are admin-defined placement applied to every + // runner Job, e.g. to target a tainted, dedicated benchmark node pool. + NodeSelector map[string]string + Tolerations []corev1.Toleration } func (c Config) Validate() error { @@ -40,9 +47,15 @@ func (c Config) Validate() error { if c.ExecutionTimeout < time.Second || c.ExecutionTimeout%time.Second != 0 { return errors.New("execution timeout must be a positive whole number of seconds") } + if c.SchedulingGracePeriod < 0 || c.SchedulingGracePeriod%time.Second != 0 { + return errors.New("scheduling grace period must be a nonnegative whole number of seconds") + } if _, err := resourceRequirements(c.Resources); err != nil { return fmt.Errorf("invalid runner resources: %w", err) } + if err := validateRunnerPlacement(c.NodeSelector, c.Tolerations); err != nil { + return fmt.Errorf("invalid runner placement: %w", err) + } return nil } diff --git a/backend/internal/coordinator/coordinator_test.go b/backend/internal/coordinator/coordinator_test.go index a0f4e7d..17022ce 100644 --- a/backend/internal/coordinator/coordinator_test.go +++ b/backend/internal/coordinator/coordinator_test.go @@ -44,6 +44,28 @@ func TestNewRequiresKubernetesClient(t *testing.T) { } } +func TestConfigSchedulingGracePeriod(t *testing.T) { + for _, test := range []struct { + name string + period time.Duration + wantError bool + }{ + {"disabled", 0, false}, + {"custom", 90 * time.Second, false}, + {"negative", -time.Second, true}, + {"fractional", 1500 * time.Millisecond, true}, + } { + t.Run(test.name, func(t *testing.T) { + config := validConfig() + config.SchedulingGracePeriod = test.period + err := config.Validate() + if (err != nil) != test.wantError { + t.Fatalf("Validate() = %v, want error %t", err, test.wantError) + } + }) + } +} + func TestNewValidatesConfigBeforeClient(t *testing.T) { invalid := validConfig() invalid.WorkloadNamespace = "" diff --git a/backend/internal/coordinator/jobs.go b/backend/internal/coordinator/jobs.go index 9cb5576..0791eff 100644 --- a/backend/internal/coordinator/jobs.go +++ b/backend/internal/coordinator/jobs.go @@ -63,6 +63,7 @@ func (c *Coordinator) waitForJobAtInterval(ctx context.Context, reference JobRef ticker := time.NewTicker(interval) defer ticker.Stop() + var scheduling schedulingTracker for { if err := ctx.Err(); err != nil { return err @@ -91,6 +92,21 @@ func (c *Coordinator) waitForJobAtInterval(ctx context.Context, reference JobRef } } + // Pod creation and scheduler decisions are asynchronous. Missing Pods, + // ambiguous results, and diagnostic API errors are not proof of an + // unschedulable run; reset the observation window and keep waiting. + if gracePeriod := c.config.SchedulingGracePeriod; gracePeriod > 0 { + pod, podErr := c.lookupJobPod(ctx, reference) + if err := ctx.Err(); err != nil { + return err + } + if podErr != nil { + scheduling = schedulingTracker{} + } else if err := scheduling.observe(pod, time.Now(), gracePeriod); err != nil { + return err + } + } + select { case <-ctx.Done(): return ctx.Err() @@ -108,6 +124,16 @@ type benchmarkResources struct { // controller ownership so a same-named Job from another run cannot match. // Multiple owned Pods are an error: choosing one arbitrarily could hide output. func (c *Coordinator) findJobPod(ctx context.Context, reference JobRef) (*corev1.Pod, error) { + pod, err := c.lookupJobPod(ctx, reference) + if err == nil && pod == nil { + return nil, fmt.Errorf("no Pod found for benchmark Job %q", reference.Name) + } + return pod, err +} + +// lookupJobPod returns nil without error while the Job has no owned Pod yet. +// Unlike final log discovery, scheduling observation must tolerate that state. +func (c *Coordinator) lookupJobPod(ctx context.Context, reference JobRef) (*corev1.Pod, error) { if c == nil { return nil, errors.New("coordinator is nil") } @@ -149,9 +175,6 @@ func (c *Coordinator) findJobPod(ctx context.Context, reference JobRef) (*corev1 } found = pod } - if found == nil { - return nil, fmt.Errorf("no Pod found for benchmark Job %q", reference.Name) - } return found, nil } @@ -255,12 +278,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, resolvedResources, labels) + job, err := c.createBenchmarkJob(ctx, secret, preparedOptions, resolvedResources, labels) if err == nil { return benchmarkResources{secret: secret, job: job}, nil } @@ -368,7 +400,7 @@ func (c *Coordinator) cleanupBenchmarkResourcesWithContext(ctx context.Context, } // createBenchmarkJob builds and creates one runner Job in the configured -// workload namespace. resolvedResources comes from the preflight check in +// workload namespace. Resources and node affinity must already be prepared by // createBenchmarkResources. It does not wait for completion or read logs. func (c *Coordinator) createBenchmarkJob(ctx context.Context, secretRef SecretRef, options Options, resolvedResources Resources, labels map[string]string) (JobRef, error) { var reference JobRef @@ -420,8 +452,8 @@ 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. -// resolvedResources must have been checked against deployment defaults before -// creating the credential Secret. +// Resources and node affinity must be prepared before creating the credential +// Secret. The manifest owns its own copy of the affinity rules. func newBenchmarkJob(config Config, name, secretName string, options Options, resolvedResources Resources, labels map[string]string) (*batchv1.Job, error) { if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid coordinator configuration: %w", err) @@ -440,6 +472,11 @@ func newBenchmarkJob(config Config, name, secretName string, options Options, re 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{ @@ -454,6 +491,9 @@ func newBenchmarkJob(config Config, name, secretName string, options Options, re Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: copyLabels(labels)}, Spec: corev1.PodSpec{ + NodeSelector: copyLabels(config.NodeSelector), + Tolerations: copyTolerations(config.Tolerations), + 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 21ee7d1..d2b148d 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" @@ -723,6 +724,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, validConfig().Resources, nil) + if err != nil { + t.Fatal(err) + } + second, err := newBenchmarkJob(validConfig(), "job-2", "secret-2", options, validConfig().Resources, 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/placement.go b/backend/internal/coordinator/placement.go new file mode 100644 index 0000000..8b93cc8 --- /dev/null +++ b/backend/internal/coordinator/placement.go @@ -0,0 +1,74 @@ +package coordinator + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// validateRunnerPlacement checks admin-defined placement at startup so that a +// typo fails the deployment instead of every benchmark Job creation. +func validateRunnerPlacement(nodeSelector map[string]string, tolerations []corev1.Toleration) error { + selectorPath := field.NewPath("runner", "nodeSelector") + for key, value := range nodeSelector { + if problems := validation.IsQualifiedName(key); len(problems) > 0 { + return field.Invalid(selectorPath.Key(key), key, problems[0]) + } + if problems := validation.IsValidLabelValue(value); len(problems) > 0 { + return field.Invalid(selectorPath.Key(key), value, problems[0]) + } + } + + tolerationsPath := field.NewPath("runner", "tolerations") + for i, toleration := range tolerations { + if err := validateToleration(toleration, tolerationsPath.Index(i)); err != nil { + return err + } + } + return nil +} + +func validateToleration(toleration corev1.Toleration, path *field.Path) error { + if toleration.Key != "" { + if problems := validation.IsQualifiedName(toleration.Key); len(problems) > 0 { + return field.Invalid(path.Child("key"), toleration.Key, problems[0]) + } + } + switch toleration.Operator { + case corev1.TolerationOpEqual, "": + if toleration.Key == "" { + return field.Invalid(path.Child("operator"), toleration.Operator, "must be Exists when key is empty") + } + if problems := validation.IsValidLabelValue(toleration.Value); len(problems) > 0 { + return field.Invalid(path.Child("value"), toleration.Value, problems[0]) + } + case corev1.TolerationOpExists: + if toleration.Value != "" { + return field.Invalid(path.Child("value"), toleration.Value, "must be empty when operator is Exists") + } + default: + return field.NotSupported(path.Child("operator"), toleration.Operator, []string{"Equal", "Exists"}) + } + switch toleration.Effect { + case "", corev1.TaintEffectNoSchedule, corev1.TaintEffectPreferNoSchedule, corev1.TaintEffectNoExecute: + default: + return field.NotSupported(path.Child("effect"), toleration.Effect, + []string{"NoSchedule", "PreferNoSchedule", "NoExecute"}) + } + if toleration.TolerationSeconds != nil && toleration.Effect != corev1.TaintEffectNoExecute { + return field.Invalid(path.Child("tolerationSeconds"), *toleration.TolerationSeconds, + "requires effect NoExecute") + } + return nil +} + +func copyTolerations(tolerations []corev1.Toleration) []corev1.Toleration { + if len(tolerations) == 0 { + return nil + } + result := make([]corev1.Toleration, len(tolerations)) + for i := range tolerations { + tolerations[i].DeepCopyInto(&result[i]) + } + return result +} diff --git a/backend/internal/coordinator/placement_test.go b/backend/internal/coordinator/placement_test.go new file mode 100644 index 0000000..b1fde24 --- /dev/null +++ b/backend/internal/coordinator/placement_test.go @@ -0,0 +1,91 @@ +package coordinator + +import ( + "reflect" + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestConfigValidatesRunnerPlacement(t *testing.T) { + seconds := int64(30) + for _, test := range []struct { + name string + nodeSelector map[string]string + tolerations []corev1.Toleration + wantError bool + }{ + {name: "empty"}, + {name: "dedicated pool", + nodeSelector: map[string]string{"node-role.example.com/benchmark": "true"}, + tolerations: []corev1.Toleration{ + {Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "benchmark", Effect: corev1.TaintEffectNoSchedule}, + {Operator: corev1.TolerationOpExists}, + {Key: "node.kubernetes.io/unreachable", Operator: corev1.TolerationOpExists, + Effect: corev1.TaintEffectNoExecute, TolerationSeconds: &seconds}, + }}, + {name: "invalid selector key", nodeSelector: map[string]string{"bad key": "x"}, wantError: true}, + {name: "invalid selector value", nodeSelector: map[string]string{"pool": "bad value"}, wantError: true}, + {name: "equal without key", tolerations: []corev1.Toleration{{Operator: corev1.TolerationOpEqual, Value: "x"}}, wantError: true}, + {name: "exists with value", tolerations: []corev1.Toleration{{Key: "k", Operator: corev1.TolerationOpExists, Value: "x"}}, wantError: true}, + {name: "unknown operator", tolerations: []corev1.Toleration{{Key: "k", Operator: "In"}}, wantError: true}, + {name: "unknown effect", tolerations: []corev1.Toleration{{Key: "k", Effect: "NoRun"}}, wantError: true}, + {name: "seconds without NoExecute", tolerations: []corev1.Toleration{ + {Key: "k", Effect: corev1.TaintEffectNoSchedule, TolerationSeconds: &seconds}}, wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + config := validConfig() + config.NodeSelector = test.nodeSelector + config.Tolerations = test.tolerations + if err := config.Validate(); (err != nil) != test.wantError { + t.Fatalf("Validate() = %v, want error %t", err, test.wantError) + } + }) + } +} + +func TestNewBenchmarkJobAppliesAdminPlacementAlongsideRunAffinity(t *testing.T) { + config := validConfig() + config.NodeSelector = map[string]string{"pool": "benchmark"} + config.Tolerations = []corev1.Toleration{ + {Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "benchmark", Effect: corev1.TaintEffectNoSchedule}, + } + wantSelector := map[string]string{"pool": "benchmark"} + wantTolerations := []corev1.Toleration{ + {Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "benchmark", Effect: corev1.TaintEffectNoSchedule}, + } + options := DefaultOptions() + options.NodeAffinity = &corev1.NodeAffinity{RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ + NodeSelectorTerms: []corev1.NodeSelectorTerm{{MatchExpressions: []corev1.NodeSelectorRequirement{ + {Key: "disktype", Operator: corev1.NodeSelectorOpIn, Values: []string{"ssd"}}, + }}}, + }} + + job, err := newBenchmarkJob(config, "job", "secret", options, config.Resources, nil) + if err != nil { + t.Fatal(err) + } + spec := job.Spec.Template.Spec + if !reflect.DeepEqual(spec.NodeSelector, wantSelector) || !reflect.DeepEqual(spec.Tolerations, wantTolerations) { + t.Fatalf("placement = %v / %v, want %v / %v", spec.NodeSelector, spec.Tolerations, wantSelector, wantTolerations) + } + if spec.Affinity == nil || !reflect.DeepEqual(spec.Affinity.NodeAffinity, options.NodeAffinity) { + t.Fatalf("per-run affinity was not applied: %+v", spec.Affinity) + } + + spec.NodeSelector["pool"] = "changed" + spec.Tolerations[0].Value = "changed" + if !reflect.DeepEqual(config.NodeSelector, wantSelector) || !reflect.DeepEqual(config.Tolerations, wantTolerations) { + t.Fatal("Job manifest shares placement state with the coordinator config") + } +} + +func TestNewBenchmarkJobOmitsEmptyAdminPlacement(t *testing.T) { + job, err := newBenchmarkJob(validConfig(), "job", "secret", DefaultOptions(), validConfig().Resources, nil) + if err != nil { + t.Fatal(err) + } + if spec := job.Spec.Template.Spec; spec.NodeSelector != nil || spec.Tolerations != nil { + t.Fatalf("unexpected placement: %v / %v", spec.NodeSelector, spec.Tolerations) + } +} diff --git a/backend/internal/coordinator/runs.go b/backend/internal/coordinator/runs.go index 54ad4ad..713984f 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,10 +49,14 @@ type Options struct { Threads int Scale int Initialize bool + // NodeAffinity is optional and is prepared before Kubernetes resource creation. + NodeAffinity *corev1.NodeAffinity // Resources contains optional per-run overrides of the deployment defaults. Resources Resources } +// 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 @@ -122,6 +128,12 @@ func (c *Coordinator) Run(ctx context.Context, connection Connection, options Op logCtx := ctx if err := c.waitForJob(ctx, resources.job); err != nil { runErr = fmt.Errorf("wait for benchmark Job %q: %w", resources.job.Name, err) + var schedulingErr *SchedulingError + if errors.As(err, &schedulingErr) { + // The scheduling failure already contains the last observed diagnostic. + // This runner never started, so no container logs are available. + return result, runErr + } // Preserve diagnostics even when the execution context has expired. // Both Pod discovery and log retrieval share this bounded budget. var cancel context.CancelFunc @@ -133,6 +145,14 @@ func (c *Coordinator) Run(ctx context.Context, connection Connection, options Op if err != nil { return result, errors.Join(runErr, fmt.Errorf("find runner Pod for Job %q: %w", resources.job.Name, err)) } + if runErr != nil { + if condition := unschedulableCondition(pod); condition != nil { + runErr = errors.Join(runErr, &SchedulingError{PodName: pod.Name, Message: condition.Message}) + } + if !runnerHasStarted(pod) { + return result, runErr + } + } result.Output, result.OutputTruncated, err = c.collectRunnerLogs(logCtx, pod) if err != nil { diff --git a/backend/internal/coordinator/runs_test.go b/backend/internal/coordinator/runs_test.go index 06fdd2e..5564226 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,82 @@ 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() + config := validConfig() + config.Resources = Resources{CPURequest: "100m", CPULimit: "1", MemoryRequest: "128Mi", MemoryLimit: "512Mi"} + 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") + } + resources := job.Spec.Template.Spec.Containers[0].Resources + if resources.Requests.Cpu().String() != "500m" || resources.Limits.Cpu().String() != "1" || + resources.Requests.Memory().String() != "128Mi" || resources.Limits.Memory().String() != "1Gi" { + t.Fatalf("Run did not apply resource overrides and defaults alongside affinity: %+v", resources) + } + 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(config, client) + if err != nil { + t.Fatal(err) + } + options := DefaultOptions() + options.NodeAffinity = input + options.Resources = Resources{CPURequest: "500m", MemoryLimit: "1Gi"} + 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 TestRunRejectsInvalidResourceOverridesBeforeKubernetesWrites(t *testing.T) { config := validConfig() config.Resources = Resources{CPURequest: "100m", CPULimit: "1"} @@ -69,6 +146,8 @@ func TestRunCollectsLogsBeforeCleanupOnFailure(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() config := validConfig() + // Exercise monitoring alongside the final diagnostic lookup. + config.SchedulingGracePeriod = time.Minute if scenario == "execution timeout" || scenario == "caller deadline" { config.ExecutionTimeout = time.Second } @@ -127,14 +206,17 @@ func TestRunCollectsLogsBeforeCleanupOnFailure(t *testing.T) { return true, &corev1.PodList{Items: []corev1.Pod{{ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{batchv1.JobNameLabel: jobName}, Name: "runner-pod", OwnerReferences: []metav1.OwnerReference{{APIVersion: "batch/v1", Kind: "Job", Name: jobName, UID: "job-uid", Controller: boolPtr(true)}}, - }}}}, nil + }, Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{ + Name: runnerContainerName, State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{}}, + }}}}}}, nil }) - checked := false - c, err := New(config, diagnosticClient{Clientset: client, check: func(logCtx context.Context) { - checked = true - deadline, ok := logCtx.Deadline() - if logCtx.Err() != nil || !ok || time.Until(deadline) <= 0 || time.Until(deadline) > failureLogTimeout { - t.Fatal("diagnostics need an active, bounded context") + diagnosticLookups := 0 + c, err := New(config, diagnosticClient{Clientset: client, check: func(lookupCtx context.Context) { + // Monitoring also lists Pods; only the diagnostic lookup gets a fresh failureLogTimeout budget. + deadline, ok := lookupCtx.Deadline() + remaining := time.Until(deadline) + if lookupCtx.Err() == nil && ok && remaining > failureLogTimeout-time.Second && remaining <= failureLogTimeout { + diagnosticLookups++ } }}) if err != nil { @@ -147,8 +229,8 @@ func TestRunCollectsLogsBeforeCleanupOnFailure(t *testing.T) { if scenario == "caller deadline" && time.Since(callerDeadline) > 500*time.Millisecond { t.Fatal("Run did not honor the earlier caller deadline") } - if err == nil || !checked { - t.Fatalf("error = %v, diagnostics attempted = %v", err, checked) + if err == nil || diagnosticLookups != 1 { + t.Fatalf("error = %v, diagnostic lookups = %d, want 1", err, diagnosticLookups) } if originalErr != nil && !errors.Is(err, originalErr) { t.Fatalf("lost original error: %v", err) diff --git a/backend/internal/coordinator/scheduling.go b/backend/internal/coordinator/scheduling.go new file mode 100644 index 0000000..4738ad6 --- /dev/null +++ b/backend/internal/coordinator/scheduling.go @@ -0,0 +1,73 @@ +package coordinator + +import ( + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +// SchedulingError contains the scheduler's placement diagnostic, not runner +// logs or connection details. It is for backend logs: the diagnostic reveals +// cluster topology, so the API reports a generic message to users. +type SchedulingError struct { + PodName string + Message string + GracePeriod time.Duration +} + +func (e *SchedulingError) Error() string { + if e.GracePeriod > 0 { + return fmt.Sprintf("benchmark runner Pod %q remained unschedulable for %s (Unschedulable): %s", + e.PodName, e.GracePeriod, e.Message) + } + return fmt.Sprintf("benchmark runner Pod %q is unschedulable (Unschedulable): %s", e.PodName, e.Message) +} + +// schedulingTracker is local to one wait loop. Use observation time rather +// than the server's transition timestamp, which may predate our observation. +type schedulingTracker struct { + podUID types.UID + since time.Time +} + +func (s *schedulingTracker) observe(pod *corev1.Pod, now time.Time, gracePeriod time.Duration) error { + condition := unschedulableCondition(pod) + if condition == nil || pod.UID == "" { + *s = schedulingTracker{} + return nil + } + if s.since.IsZero() || s.podUID != pod.UID { + s.podUID, s.since = pod.UID, now + } + if now.Sub(s.since) < gracePeriod { + return nil + } + return &SchedulingError{PodName: pod.Name, Message: condition.Message, GracePeriod: gracePeriod} +} + +func unschedulableCondition(pod *corev1.Pod) *corev1.PodCondition { + // Ignore stale conditions on already-bound or terminating Pods. + if pod == nil || pod.Spec.NodeName != "" || pod.DeletionTimestamp != nil { + return nil + } + for i := range pod.Status.Conditions { + condition := &pod.Status.Conditions[i] + if condition.Type == corev1.PodScheduled && condition.Status == corev1.ConditionFalse && + condition.Reason == corev1.PodReasonUnschedulable { + return condition + } + } + return nil +} + +func runnerHasStarted(pod *corev1.Pod) bool { + for _, status := range pod.Status.ContainerStatuses { + if status.Name == runnerContainerName { + return status.State.Running != nil || status.State.Terminated != nil || + status.LastTerminationState.Terminated != nil + } + } + return false +} diff --git a/backend/internal/coordinator/scheduling_test.go b/backend/internal/coordinator/scheduling_test.go new file mode 100644 index 0000000..ab3c263 --- /dev/null +++ b/backend/internal/coordinator/scheduling_test.go @@ -0,0 +1,362 @@ +package coordinator + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "testing/synctest" + "time" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +func schedulingTestPod(ref JobRef) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "runner-pod", Namespace: validConfig().WorkloadNamespace, UID: "pod-uid", + Labels: map[string]string{batchv1.JobNameLabel: ref.Name}, + OwnerReferences: []metav1.OwnerReference{{APIVersion: "batch/v1", Kind: "Job", + Name: ref.Name, UID: ref.UID, Controller: boolPtr(true)}}, + }, + Status: corev1.PodStatus{Phase: corev1.PodPending, Conditions: []corev1.PodCondition{{ + Type: corev1.PodScheduled, Status: corev1.ConditionFalse, + Reason: corev1.PodReasonUnschedulable, Message: "0/2 nodes match the required node affinity", + }}}, + } +} + +func TestRunnerHasStarted(t *testing.T) { + for _, test := range []struct { + name string + status corev1.ContainerStatus + want bool + }{ + {"running", corev1.ContainerStatus{Name: runnerContainerName, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}, true}, + {"terminated", corev1.ContainerStatus{Name: runnerContainerName, State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{}}}, true}, + {"restarting", corev1.ContainerStatus{Name: runnerContainerName, LastTerminationState: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{}}}, true}, + {"waiting only", corev1.ContainerStatus{Name: runnerContainerName, State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "ImagePullBackOff"}}}, false}, + {"another container", corev1.ContainerStatus{Name: "other", State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}, false}, + {"no container status", corev1.ContainerStatus{}, false}, + } { + t.Run(test.name, func(t *testing.T) { + pod := &corev1.Pod{Status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{test.status}}} + if got := runnerHasStarted(pod); got != test.want { + t.Fatalf("runnerHasStarted = %t, want %t", got, test.want) + } + }) + } +} + +func TestSchedulingTrackerGraceAndResets(t *testing.T) { + const gracePeriod = time.Minute + start := time.Unix(100, 0) + for _, test := range []struct { + name string + mutate func(*corev1.Pod) *corev1.Pod + }{ + {"missing pod", func(*corev1.Pod) *corev1.Pod { return nil }}, + {"missing UID", func(p *corev1.Pod) *corev1.Pod { p.UID = ""; return p }}, + {"replacement pod", func(p *corev1.Pod) *corev1.Pod { p.UID = "replacement"; return p }}, + {"no condition", func(p *corev1.Pod) *corev1.Pod { p.Status.Conditions = nil; return p }}, + {"unknown condition", func(p *corev1.Pod) *corev1.Pod { p.Status.Conditions[0].Status = corev1.ConditionUnknown; return p }}, + {"scheduled condition", func(p *corev1.Pod) *corev1.Pod { p.Status.Conditions[0].Status = corev1.ConditionTrue; return p }}, + {"different reason", func(p *corev1.Pod) *corev1.Pod { p.Status.Conditions[0].Reason = "SchedulingGated"; return p }}, + {"bound with stale condition", func(p *corev1.Pod) *corev1.Pod { p.Spec.NodeName = "node"; return p }}, + {"terminating pod", func(p *corev1.Pod) *corev1.Pod { now := metav1.NewTime(start); p.DeletionTimestamp = &now; return p }}, + } { + t.Run(test.name, func(t *testing.T) { + pod := schedulingTestPod(JobRef{Name: "job", UID: "job-uid"}) + var tracker schedulingTracker + if err := tracker.observe(pod, start, gracePeriod); err != nil { + t.Fatal(err) + } + if err := tracker.observe(test.mutate(pod.DeepCopy()), start.Add(50*time.Second), gracePeriod); err != nil { + t.Fatal(err) + } + if err := tracker.observe(pod, start.Add(60*time.Second), gracePeriod); err != nil { + t.Fatalf("timer was not reset: %v", err) + } + }) + } + + var tracker schedulingTracker + pod := schedulingTestPod(JobRef{Name: "job", UID: "job-uid"}) + // Server timestamps must not make the grace period expire immediately. + pod.Status.Conditions[0].LastTransitionTime = metav1.NewTime(start.Add(-time.Hour)) + if err := tracker.observe(pod, start, gracePeriod); err != nil { + t.Fatal(err) + } + if err := tracker.observe(pod, start.Add(gracePeriod-time.Nanosecond), gracePeriod); err != nil { + t.Fatalf("failed before the grace boundary: %v", err) + } + pod.Status.Conditions[0].Message = "latest scheduling diagnosis" + err := tracker.observe(pod, start.Add(gracePeriod), gracePeriod) + var schedulingErr *SchedulingError + if !errors.As(err, &schedulingErr) || schedulingErr.Message != pod.Status.Conditions[0].Message { + t.Fatalf("want scheduling failure with latest message, got %v", err) + } +} + +// synctest advances Go's clock and timers without a real one-minute wait. +func TestWaitForJobScheduling(t *testing.T) { + for _, test := range []struct { + name string + wantTime time.Duration + want string + }{ + {"persistent", 60 * time.Second, "scheduling"}, + {"delayed pod creation", 80 * time.Second, "scheduling"}, + {"replaced pod", 100 * time.Second, "scheduling"}, + {"missing condition gap", 105 * time.Second, "scheduling"}, + {"API failure gap", 105 * time.Second, "scheduling"}, + {"temporary then scheduled", 90 * time.Second, "success"}, + {"image pull pending", 90 * time.Second, "deadline"}, + {"foreign owner", 90 * time.Second, "deadline"}, + {"multiple owned pods", 90 * time.Second, "deadline"}, + {"missing pod", 90 * time.Second, "deadline"}, + {"terminal job wins", 60 * time.Second, "success"}, + {"failed job wins", 30 * time.Second, "job failure"}, + {"cancellation", 20 * time.Second, "cancelled"}, + } { + t.Run(test.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + start := time.Now() + ref := JobRef{Name: "job", UID: "job-uid"} + deadline := 120 * time.Second + if test.want == "deadline" { + deadline = test.wantTime + } + ctx, cancel := context.WithTimeout(context.Background(), deadline) + defer cancel() + client := fake.NewSimpleClientset() + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + job := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: ref.Name, UID: ref.UID}} + elapsed := time.Since(start) + if test.want == "success" && elapsed >= test.wantTime { + job.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}} + } + if test.want == "job failure" && elapsed >= test.wantTime { + job.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobFailed, Status: corev1.ConditionTrue}} + } + if test.want == "cancelled" && elapsed >= test.wantTime { + cancel() + } + return true, job, nil + }) + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + elapsed := time.Since(start) + pod := schedulingTestPod(ref) + pod.Status.Conditions[0].Message = fmt.Sprintf("latest diagnosis at %s", elapsed) + switch test.name { + case "delayed pod creation": + if elapsed < 20*time.Second { + return true, &corev1.PodList{}, nil + } + case "replaced pod": + if elapsed >= 40*time.Second { + pod.UID = "replacement" + } + case "missing condition gap": + if elapsed >= 40*time.Second && elapsed < 45*time.Second { + pod.Status.Conditions = nil + } + case "API failure gap": + if elapsed >= 40*time.Second && elapsed < 45*time.Second { + return true, nil, errors.New("temporary API failure") + } + case "temporary then scheduled", "image pull pending": + if test.name == "image pull pending" || elapsed >= 30*time.Second { + pod.Spec.NodeName = "node" + pod.Status.Conditions[0].Status = corev1.ConditionTrue + pod.Status.ContainerStatuses = []corev1.ContainerStatus{{Name: runnerContainerName, + State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "ImagePullBackOff"}}}} + } + case "foreign owner": + pod.OwnerReferences[0].UID = "another-job" + case "missing pod": + return true, &corev1.PodList{}, nil + case "multiple owned pods": + other := pod.DeepCopy() + other.Name, other.UID = "other", "other-uid" + return true, &corev1.PodList{Items: []corev1.Pod{*pod, *other}}, nil + } + return true, &corev1.PodList{Items: []corev1.Pod{*pod}}, nil + }) + config := validConfig() + config.SchedulingGracePeriod = time.Minute + c, err := New(config, client) + if err != nil { + t.Fatal(err) + } + err = c.waitForJob(ctx, ref) + var schedulingErr *SchedulingError + switch test.want { + case "scheduling": + if !errors.As(err, &schedulingErr) || schedulingErr.Message != fmt.Sprintf("latest diagnosis at %s", test.wantTime) { + t.Fatalf("want latest scheduling diagnostic, got %v", err) + } + case "success": + if err != nil { + t.Fatal(err) + } + case "deadline": + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("want deadline, got %v", err) + } + case "cancelled": + if !errors.Is(err, context.Canceled) { + t.Fatalf("want cancellation, got %v", err) + } + case "job failure": + if err == nil || !strings.Contains(err.Error(), "failed") { + t.Fatalf("want Job failure, got %v", err) + } + } + if elapsed := time.Since(start); elapsed != test.wantTime { + t.Fatalf("elapsed = %s, want %s", elapsed, test.wantTime) + } + }) + }) + } +} + +func TestWaitForJobConfigurableSchedulingGracePeriod(t *testing.T) { + for _, test := range []struct { + name string + gracePeriod, completeAt, wantTime time.Duration + }{ + {"disabled allows delayed scheduling", 0, 90 * time.Second, 90 * time.Second}, + {"disabled remains bounded", 0, 0, 3 * time.Minute}, + {"custom period fails early", 90 * time.Second, 0, 90 * time.Second}, + {"longer period allows delayed scheduling", 2 * time.Minute, 90 * time.Second, 90 * time.Second}, + } { + t.Run(test.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + start := time.Now() + ref := JobRef{Name: "job", UID: "job-uid"} + client := fake.NewSimpleClientset() + client.PrependReactor("get", "jobs", func(ktesting.Action) (bool, runtime.Object, error) { + job := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: ref.Name, UID: ref.UID}} + if test.completeAt > 0 && time.Since(start) >= test.completeAt { + job.Status.Conditions = []batchv1.JobCondition{{Type: batchv1.JobComplete, Status: corev1.ConditionTrue}} + } + return true, job, nil + }) + podLookups := 0 + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + podLookups++ + return true, &corev1.PodList{Items: []corev1.Pod{*schedulingTestPod(ref)}}, nil + }) + config := validConfig() + config.SchedulingGracePeriod = test.gracePeriod + c, err := New(config, client) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + err = c.waitForJob(ctx, ref) + var schedulingErr *SchedulingError + switch { + case test.completeAt > 0: + if err != nil { + t.Fatal(err) + } + case test.gracePeriod > 0: + if !errors.As(err, &schedulingErr) || schedulingErr.GracePeriod != test.gracePeriod { + t.Fatalf("want configured scheduling error, got %v", err) + } + default: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("want overall deadline, got %v", err) + } + } + if test.gracePeriod == 0 && podLookups != 0 { + t.Fatalf("disabled monitoring made %d Pod lookups", podLookups) + } + if elapsed := time.Since(start); elapsed != test.wantTime { + t.Fatalf("elapsed = %s, want %s", elapsed, test.wantTime) + } + }) + }) + } +} + +func TestRunSchedulingFailureCleansUpWithoutLogs(t *testing.T) { + for _, scenario := range []string{"early failure", "overall timeout", "diagnostic lookup failure"} { + t.Run(scenario, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + client := fake.NewSimpleClientset() + config := validConfig() + config.ExecutionTimeout = 5 * time.Minute + if scenario == "early failure" { + config.SchedulingGracePeriod = time.Minute + } else { + config.ExecutionTimeout = 30 * time.Second + } + var ref JobRef + start := time.Now() + 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) + job.UID = "job-uid" + ref = JobRef{Name: job.Name, UID: job.UID} + return false, nil, nil + }) + client.PrependReactor("list", "pods", func(ktesting.Action) (bool, runtime.Object, error) { + if scenario == "diagnostic lookup failure" && time.Since(start) >= config.ExecutionTimeout { + return true, nil, errors.New("diagnostic API unavailable") + } + return true, &corev1.PodList{Items: []corev1.Pod{*schedulingTestPod(ref)}}, nil + }) + c, err := New(config, client) + if err != nil { + t.Fatal(err) + } + result, err := c.Run(context.Background(), validConnection(), DefaultOptions()) + var schedulingErr *SchedulingError + if scenario == "diagnostic lookup failure" { + if !strings.Contains(err.Error(), "diagnostic API unavailable") { + t.Fatalf("lost diagnostic error: %v", err) + } + } else if !errors.As(err, &schedulingErr) || !strings.Contains(err.Error(), "required node affinity") { + t.Fatalf("want scheduler explanation, got %v", err) + } + if scenario != "early failure" && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("lost original timeout: %v", err) + } + if result.JobName != ref.Name || result.Output != "" { + t.Fatalf("unexpected result: %+v", result) + } + wantTime := config.ExecutionTimeout + if scenario == "early failure" { + wantTime = config.SchedulingGracePeriod + } + if elapsed := time.Since(start); elapsed != wantTime { + t.Fatalf("elapsed = %s, want %s", elapsed, wantTime) + } + jobDeleted, secretDeleted := false, false + for _, action := range client.Actions() { + if action.GetSubresource() == "log" { + t.Fatal("requested logs for a runner that never started") + } + jobDeleted = jobDeleted || action.Matches("delete", "jobs") + secretDeleted = secretDeleted || action.Matches("delete", "secrets") + } + if !jobDeleted || !secretDeleted { + t.Fatal("failed run did not clean up both resources") + } + }) + }) + } +} diff --git a/backend/main.go b/backend/main.go index d33ba8c..61d5353 100644 --- a/backend/main.go +++ b/backend/main.go @@ -18,6 +18,7 @@ import ( "github.com/openeverest/plugin-bench/backend/internal/api" "github.com/openeverest/plugin-bench/backend/internal/coordinator" "github.com/openeverest/plugin-bench/backend/internal/everest" + corev1 "k8s.io/api/core/v1" ) // The frontend bundle is copied here by the Docker build. CI creates a @@ -31,6 +32,10 @@ var iconData []byte const shutdownTimeout = 30 * time.Second +// Long enough for a cluster autoscaler to add a node, short enough that an +// impossible placement rule doesn't hold a run slot for the whole timeout. +const defaultSchedulingGracePeriod = "2m" + func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) @@ -75,12 +80,27 @@ func coordinatorConfigFromEnv() (coordinator.Config, error) { if err != nil { return coordinator.Config{}, fmt.Errorf("parse BENCHMARK_EXECUTION_TIMEOUT: %w", err) } + schedulingGracePeriod, err := time.ParseDuration(envOrDefault("BENCHMARK_SCHEDULING_GRACE_PERIOD", defaultSchedulingGracePeriod)) + if err != nil { + return coordinator.Config{}, fmt.Errorf("parse BENCHMARK_SCHEDULING_GRACE_PERIOD: %w", err) + } + var nodeSelector map[string]string + if err := decodeJSONEnv("RUNNER_NODE_SELECTOR", &nodeSelector); err != nil { + return coordinator.Config{}, err + } + var tolerations []corev1.Toleration + if err := decodeJSONEnv("RUNNER_TOLERATIONS", &tolerations); err != nil { + return coordinator.Config{}, err + } return coordinator.Config{ - WorkloadNamespace: os.Getenv("WORKLOAD_NAMESPACE"), - RunnerImage: os.Getenv("RUNNER_IMAGE"), - ImagePullPolicy: envOrDefault("RUNNER_IMAGE_PULL_POLICY", "IfNotPresent"), - RunnerServiceAccount: os.Getenv("RUNNER_SERVICE_ACCOUNT_NAME"), - ExecutionTimeout: executionTimeout, + WorkloadNamespace: os.Getenv("WORKLOAD_NAMESPACE"), + RunnerImage: os.Getenv("RUNNER_IMAGE"), + ImagePullPolicy: envOrDefault("RUNNER_IMAGE_PULL_POLICY", "IfNotPresent"), + RunnerServiceAccount: os.Getenv("RUNNER_SERVICE_ACCOUNT_NAME"), + ExecutionTimeout: executionTimeout, + SchedulingGracePeriod: schedulingGracePeriod, + NodeSelector: nodeSelector, + Tolerations: tolerations, Resources: coordinator.Resources{ CPURequest: os.Getenv("RUNNER_CPU_REQUEST"), MemoryRequest: os.Getenv("RUNNER_MEMORY_REQUEST"), @@ -97,6 +117,24 @@ func envOrDefault(key, fallback string) string { return fallback } +// decodeJSONEnv leaves target unchanged when the variable is unset. Unknown +// fields are rejected so misspelled Helm values fail at startup. +func decodeJSONEnv(key string, target any) error { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return nil + } + decoder := json.NewDecoder(strings.NewReader(value)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("parse %s: %w", key, err) + } + if decoder.More() { + return fmt.Errorf("parse %s: unexpected data after JSON value", key) + } + return nil +} + func run() error { config, err := coordinatorConfigFromEnv() if err != nil { diff --git a/backend/main_test.go b/backend/main_test.go new file mode 100644 index 0000000..431daeb --- /dev/null +++ b/backend/main_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "reflect" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" +) + +func TestCoordinatorConfigRunnerPlacementFromEnv(t *testing.T) { + t.Setenv("RUNNER_NODE_SELECTOR", `{"pool":"benchmark"}`) + t.Setenv("RUNNER_TOLERATIONS", `[{"key":"dedicated","operator":"Equal","value":"benchmark","effect":"NoSchedule"}]`) + config, err := coordinatorConfigFromEnv() + if err != nil { + t.Fatal(err) + } + wantTolerations := []corev1.Toleration{ + {Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "benchmark", Effect: corev1.TaintEffectNoSchedule}, + } + if !reflect.DeepEqual(config.NodeSelector, map[string]string{"pool": "benchmark"}) || + !reflect.DeepEqual(config.Tolerations, wantTolerations) { + t.Fatalf("placement = %v / %v", config.NodeSelector, config.Tolerations) + } + + // Helm renders empty values as "{}" and "[]". + t.Setenv("RUNNER_NODE_SELECTOR", "{}") + t.Setenv("RUNNER_TOLERATIONS", "[]") + config, err = coordinatorConfigFromEnv() + if err != nil || len(config.NodeSelector) != 0 || len(config.Tolerations) != 0 { + t.Fatalf("empty placement = %v / %v, error = %v", config.NodeSelector, config.Tolerations, err) + } +} + +func TestCoordinatorConfigRejectsMalformedRunnerPlacement(t *testing.T) { + for _, test := range []struct{ key, value string }{ + {"RUNNER_NODE_SELECTOR", `["pool"]`}, + {"RUNNER_NODE_SELECTOR", `{"pool":"a"} {}`}, + {"RUNNER_TOLERATIONS", `[{"key":"dedicated","efect":"NoSchedule"}]`}, + } { + t.Run(test.value, func(t *testing.T) { + t.Setenv(test.key, test.value) + if _, err := coordinatorConfigFromEnv(); err == nil || !strings.Contains(err.Error(), test.key) { + t.Fatalf("want %s parsing error, got %v", test.key, err) + } + }) + } +} + +func TestCoordinatorConfigSchedulingGracePeriodFromEnv(t *testing.T) { + t.Setenv("BENCHMARK_EXECUTION_TIMEOUT", "5m") + for _, test := range []struct { + name, value string + want time.Duration + wantError bool + }{ + {"default", "", 2 * time.Minute, false}, + {"disabled", "0", 0, false}, + {"custom", "90s", 90 * time.Second, false}, + {"malformed", "invalid", 0, true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("BENCHMARK_SCHEDULING_GRACE_PERIOD", test.value) + config, err := coordinatorConfigFromEnv() + if test.wantError { + if err == nil || !strings.Contains(err.Error(), "BENCHMARK_SCHEDULING_GRACE_PERIOD") { + t.Fatalf("want grace period parsing error, got %v", err) + } + return + } + if err != nil || config.SchedulingGracePeriod != test.want || config.ExecutionTimeout != 5*time.Minute { + t.Fatalf("config = %+v, error = %v; want grace %s and execution timeout 5m", config, err, test.want) + } + }) + } +} diff --git a/charts/plugin-bench/templates/deployment.yaml b/charts/plugin-bench/templates/deployment.yaml index 8184733..e107da9 100644 --- a/charts/plugin-bench/templates/deployment.yaml +++ b/charts/plugin-bench/templates/deployment.yaml @@ -41,6 +41,12 @@ spec: value: {{ .Values.runner.image.pullPolicy | quote }} - name: BENCHMARK_EXECUTION_TIMEOUT value: {{ .Values.runner.executionTimeout | quote }} + - name: BENCHMARK_SCHEDULING_GRACE_PERIOD + value: {{ .Values.runner.schedulingGracePeriod | quote }} + - name: RUNNER_NODE_SELECTOR + value: {{ .Values.runner.nodeSelector | default dict | toJson | quote }} + - name: RUNNER_TOLERATIONS + value: {{ .Values.runner.tolerations | default list | toJson | quote }} - name: RUNNER_CPU_REQUEST value: {{ .Values.runner.resources.requests.cpu | quote }} - name: RUNNER_MEMORY_REQUEST diff --git a/charts/plugin-bench/values.yaml b/charts/plugin-bench/values.yaml index f411465..48cf73c 100644 --- a/charts/plugin-bench/values.yaml +++ b/charts/plugin-bench/values.yaml @@ -55,6 +55,20 @@ runner: tag: "" pullPolicy: IfNotPresent executionTimeout: 5m + # Fail a run whose Pod stays unschedulable this long. Allow enough time for + # node autoscaling. "0" disables early failure (executionTimeout still applies). + schedulingGracePeriod: 2m + # Applied to every runner Job, e.g. to target a dedicated benchmark node pool: + # nodeSelector: + # node-role/benchmark: "true" + # tolerations: + # - key: dedicated + # operator: Equal + # value: benchmark + # effect: NoSchedule + # Per-run node affinity from the UI is applied in addition to these. + nodeSelector: {} + tolerations: [] resources: requests: cpu: 100m diff --git a/package-lock.json b/package-lock.json index a1f0b23..9fc2bee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,18 +11,36 @@ "@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": { "@openeverest/plugin-sdk": "^0.4.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", "@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", "typescript": "^5.2.2", - "vite": "^8.2.0" + "vite": "^8.2.0", + "vitest": "^4.1.11" + } + }, + "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": { @@ -164,6 +182,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", @@ -880,6 +1013,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.1.tgz", @@ -1160,6 +1300,86 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -1218,6 +1438,186 @@ "vite": "^4 || ^5 || ^6 || ^7 || ^8" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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 +1633,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", @@ -1242,6 +1656,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1251,6 +1675,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 +1719,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,9 +1777,36 @@ } } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", @@ -1315,6 +1814,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -1325,6 +1831,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 +1868,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 +1887,42 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "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", @@ -1355,6 +1935,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1379,6 +1979,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 +2020,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 +2128,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 +2219,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 +2582,66 @@ "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/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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 +2667,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", @@ -1816,6 +2683,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -1846,6 +2727,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", @@ -1861,6 +2755,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1909,6 +2810,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -1926,6 +2849,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", @@ -1973,6 +2926,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 +2997,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", @@ -2046,6 +3033,13 @@ "loose-envify": "^1.1.0" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -2065,6 +3059,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -2083,6 +3091,30 @@ "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/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2100,6 +3132,45 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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 +3185,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 +3283,213 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "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..03dc047 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "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/benchmarkFormValidation.ts --target ES2020 --module ESNext --moduleResolution Bundler --outDir dist/form-test && node tests/benchmarkForm.test.mjs && node tests/benchmarkNodeAffinity.test.mjs", + "test:components": "vitest run", "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", @@ -22,12 +23,16 @@ }, "devDependencies": { "@openeverest/plugin-sdk": "^0.4.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", "@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", "typescript": "^5.2.2", - "vite": "^8.2.0" + "vite": "^8.2.0", + "vitest": "^4.1.11" } } diff --git a/src/BenchmarkForm.tsx b/src/BenchmarkForm.tsx index 259c1c5..7857a61 100644 --- a/src/BenchmarkForm.tsx +++ b/src/BenchmarkForm.tsx @@ -20,6 +20,8 @@ import { BenchmarkTargetSummary } from './BenchmarkTargetSummary'; import { initialBenchmarkFormValues, validateBenchmarkForm } from './benchmarkFormValidation'; import type { BenchmarkFormField, BenchmarkFormValues } from './benchmarkFormValidation'; import type { BenchmarkTarget } from './benchmarkTarget'; +import { createNodeAffinityDraft, prepareNodeAffinityDraft } from './benchmarkNodeAffinity'; +import { BenchmarkNodeAffinityForm } from './BenchmarkNodeAffinityForm'; type NumericField = 'durationSeconds' | 'clients' | 'threads' | 'scale'; type ResourceField = 'cpuRequest' | 'cpuLimit' | 'memoryRequest' | 'memoryLimit'; @@ -45,11 +47,14 @@ 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 [resourcesExpanded, setResourcesExpanded] = useState(false); 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 resourceError = RESOURCE_FIELDS.some(({ field }) => Boolean(errors[field])); const updateValue = (key: K, value: BenchmarkFormValues[K]) => { @@ -175,6 +180,9 @@ export function BenchmarkForm({ target, isSubmitting, onSubmit }: BenchmarkFormP + updateValue('nodeAffinity', draft)} /> diff --git a/src/BenchmarkNodeAffinityForm.tsx b/src/BenchmarkNodeAffinityForm.tsx new file mode 100644 index 0000000..72863cd --- /dev/null +++ b/src/BenchmarkNodeAffinityForm.tsx @@ -0,0 +1,84 @@ +import { useId, useState } from 'react'; +import { + Accordion, AccordionDetails, AccordionSummary, Alert, Button, Chip, + FormControlLabel, Stack, Switch, TextField, Typography, +} from '@mui/material'; +import { createNodeLabelRow } from './benchmarkNodeAffinity'; +import type { NodeAffinityDraft, NodeAffinityErrors, NodeLabelRow } from './benchmarkNodeAffinity'; + +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 [touched, setTouched] = useState>(new Set()); + const errorCount = Object.keys(errors).length; + const visibleError = (path: string) => (touched.has(path) ? errors[path] : undefined); + const markTouched = (path: string) => setTouched(current => new Set(current).add(path)); + const updateRow = (row: NodeLabelRow) => + onChange({ ...draft, rows: draft.rows.map(item => (item.id === row.id ? row : item)) }); + + return ( + setExpanded(open)}> + + + + onChange({ + ...draft, + enabled: event.target.checked, + rows: event.target.checked && draft.rows.length === 0 ? [createNodeLabelRow()] : draft.rows, + })} /> + } /> + + Rules apply to benchmark runner nodes in the cluster hosting this plugin, not the database instance. + Leave this off to use the deployment's default placement. + + {draft.enabled && <> + + The runner only starts on a node that has all of these labels. Runs fail if no such node can take it. + + {errors.nodeAffinity && {errors.nodeAffinity}} + {draft.rows.map((row, index) => ( + + markTouched(`${row.id}.key`)} + onChange={event => updateRow({ ...row, key: event.target.value })} /> + markTouched(`${row.id}.value`)} + onChange={event => updateRow({ ...row, value: event.target.value })} /> + + + ))} + + } + + + + ); +} 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 07c8848..40d882a 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', @@ -131,6 +132,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 ba13f00..02ecee6 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 BenchmarkRunResources = { cpuRequest?: string; @@ -18,6 +20,7 @@ export type CreateBenchmarkRunRequest = { scale: number; initialize: boolean; resources?: BenchmarkRunResources; + nodeAffinity?: NodeAffinity; }; export type CreateBenchmarkRunResponse = { @@ -33,6 +36,8 @@ export type BenchmarkRun = { database?: string; resources?: BenchmarkRunResources; createdAt: string; + /** Absent on older backends; an empty object means no configured affinity. */ + nodeAffinity?: NodeAffinity; completedAt?: string; jobName?: string; output?: string; @@ -113,6 +118,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)) && (run.resources === undefined || isBenchmarkRunResources(run.resources)) && diff --git a/src/benchmarkFormValidation.ts b/src/benchmarkFormValidation.ts index f84501d..e5329be 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; @@ -12,6 +14,7 @@ export type BenchmarkFormValues = { memoryRequest: string; memoryLimit: string; initialize: boolean; + nodeAffinity: NodeAffinityDraft; }; export const initialBenchmarkFormValues: BenchmarkFormValues = { @@ -25,9 +28,10 @@ export const initialBenchmarkFormValues: BenchmarkFormValues = { memoryRequest: '', memoryLimit: '', initialize: true, + nodeAffinity: createNodeAffinityDraft(), }; -export type BenchmarkFormField = Exclude; +export type BenchmarkFormField = Exclude; export type BenchmarkFormErrors = Partial>; const maxInt32 = 2_147_483_647; @@ -109,6 +113,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(); const resources = { @@ -126,5 +132,6 @@ export function toCreateBenchmarkRunRequest( scale: values.initialize ? Number(values.scale) : 1, initialize: values.initialize, ...(Object.keys(resources).length > 0 ? { resources } : {}), + ...(affinity.nodeAffinity ? { nodeAffinity: affinity.nodeAffinity } : {}), }; } diff --git a/src/benchmarkNodeAffinity.ts b/src/benchmarkNodeAffinity.ts new file mode 100644 index 0000000..4e4bd32 --- /dev/null +++ b/src/benchmarkNodeAffinity.ts @@ -0,0 +1,88 @@ +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 editor only builds "label key = value" rules; all rows must match (AND). +export type NodeLabelRow = { id: number; key: string; value: string }; +export type NodeAffinityDraft = { enabled: boolean; rows: NodeLabelRow[] }; +// Keyed by `${row.id}.key` / `${row.id}.value`, or `nodeAffinity` for the whole draft. +export type NodeAffinityErrors = Record; +export type PreparedNodeAffinityDraft = + | { ok: true; nodeAffinity?: NodeAffinity } + | { ok: false; errors: NodeAffinityErrors }; + +let nextRowId = 0; + +// Stable IDs keep React state attached to the right row when rows are removed. +export function createNodeLabelRow(): NodeLabelRow { + nextRowId += 1; + return { id: nextRowId, key: '', value: '' }; +} + +export function createNodeAffinityDraft(): NodeAffinityDraft { + return { enabled: false, rows: [] }; +} + +// Basic checks only; the backend validates Kubernetes label syntax and its +// 400 response is shown to the user. +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.rows.length === 0) { + return { ok: false, errors: { nodeAffinity: 'Add at least one node label.' } }; + } + const errors: NodeAffinityErrors = {}; + const seenKeys = new Set(); + for (const row of draft.rows) { + const key = row.key.trim(); + if (key === '') { + errors[`${row.id}.key`] = 'Enter a label key.'; + } else if (seenKeys.has(key)) { + errors[`${row.id}.key`] = 'Each label key can be used once.'; + } + seenKeys.add(key); + if (row.value.trim() === '') errors[`${row.id}.value`] = 'Enter a label value.'; + } + if (Object.keys(errors).length > 0) return { ok: false, errors }; + return { ok: true, nodeAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ + matchExpressions: draft.rows.map(row => ({ key: row.key.trim(), operator: 'In', values: [row.value.trim()] })), + }] }, + } }; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const isRequirementList = (value: unknown): boolean => + value === undefined || (Array.isArray(value) && value.every(item => isRecord(item) && + typeof item.key === 'string' && typeof item.operator === 'string' && + (item.values === undefined || (Array.isArray(item.values) && item.values.every(v => typeof v === 'string'))))); + +const isTerm = (value: unknown): boolean => + isRecord(value) && isRequirementList(value.matchExpressions) && isRequirementList(value.matchFields); + +// Shape check for status responses so the summary can render them safely. +export function isNodeAffinity(value: unknown): value is NodeAffinity { + if (!isRecord(value)) return false; + const required = value.requiredDuringSchedulingIgnoredDuringExecution; + const preferred = value.preferredDuringSchedulingIgnoredDuringExecution; + return (required === undefined || + (isRecord(required) && Array.isArray(required.nodeSelectorTerms) && required.nodeSelectorTerms.every(isTerm))) && + (preferred === undefined || (Array.isArray(preferred) && preferred.every(item => + isRecord(item) && typeof item.weight === 'number' && isTerm(item.preference)))); +} diff --git a/tests/benchmarkApi.test.mjs b/tests/benchmarkApi.test.mjs index fe347f5..cee698f 100644 --- a/tests/benchmarkApi.test.mjs +++ b/tests/benchmarkApi.test.mjs @@ -100,3 +100,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 25ee77d..f419fe2 100644 --- a/tests/benchmarkForm.test.mjs +++ b/tests/benchmarkForm.test.mjs @@ -62,6 +62,28 @@ test('enabling initialization again requires a valid scale and uses its value', assert.equal(toCreateBenchmarkRunRequest({ ...values, initialize: true, scale: '10' }, target).scale, 10); }); +test('request mapping omits disabled affinity and includes enabled label rows', () => { + assert.equal(initialBenchmarkFormValues.nodeAffinity.enabled, false); + const nodeAffinity = { enabled: false, rows: [{ id: 1, key: 'pool', value: 'benchmark' }] }; + const values = { ...validValues, nodeAffinity }; + assert.equal('nodeAffinity' in toCreateBenchmarkRunRequest(values, target), false); + assert.equal('nodeAffinity' in toCreateBenchmarkRunRequest(validValues, target), false); + const enabled = { ...values, nodeAffinity: { ...nodeAffinity, enabled: true } }; + assert.deepEqual(toCreateBenchmarkRunRequest(enabled, target).nodeAffinity, { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ + matchExpressions: [{ key: 'pool', operator: 'In', values: ['benchmark'] }], + }] }, + }); + 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, rows: [{ id: 1, key: 'pool', value: '' }] } }; + assert.deepEqual(validateBenchmarkForm(values), {}); + assert.equal(toCreateBenchmarkRunRequest(values, target), undefined); + assert.ok(toCreateBenchmarkRunRequest({ ...values, nodeAffinity: { ...values.nodeAffinity, enabled: false } }, target)); +}); + test('resource fields are optional and omitted when empty', () => { for (const field of ['cpuRequest', 'cpuLimit', 'memoryRequest', 'memoryLimit']) { assert.equal(initialBenchmarkFormValues[field], ''); diff --git a/tests/benchmarkNodeAffinity.test.mjs b/tests/benchmarkNodeAffinity.test.mjs new file mode 100644 index 0000000..9292aed --- /dev/null +++ b/tests/benchmarkNodeAffinity.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createNodeAffinityDraft, createNodeLabelRow, isNodeAffinity, prepareNodeAffinityDraft, +} from '../dist/form-test/benchmarkNodeAffinity.js'; + +const row = (key, value) => ({ ...createNodeLabelRow(), key, value }); + +test('disabled drafts omit affinity without discarding entered rows', () => { + const draft = { enabled: false, rows: [row('', '')] }; + const before = structuredClone(draft); + assert.deepEqual(prepareNodeAffinityDraft(draft), { ok: true }); + assert.deepEqual(draft, before); + assert.deepEqual(prepareNodeAffinityDraft(createNodeAffinityDraft()), { ok: true }); +}); + +test('rows become one required term whose label conditions must all match', () => { + const result = prepareNodeAffinityDraft({ enabled: true, rows: [row(' pool ', ' benchmark '), row('disk', 'ssd')] }); + assert.deepEqual(result, { ok: true, nodeAffinity: { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [ + { key: 'pool', operator: 'In', values: ['benchmark'] }, + { key: 'disk', operator: 'In', values: ['ssd'] }, + ] }] }, + } }); +}); + +test('blank keys, blank values, duplicate keys, and empty drafts are rejected by row ID', () => { + assert.deepEqual(prepareNodeAffinityDraft({ enabled: true, rows: [] }), + { ok: false, errors: { nodeAffinity: 'Add at least one node label.' } }); + + const blankKey = row(' ', 'x'); + const blankValue = row('pool', ' '); + const duplicate = row('pool', 'other'); + const result = prepareNodeAffinityDraft({ enabled: true, rows: [blankKey, blankValue, duplicate] }); + assert.equal(result.ok, false); + assert.deepEqual(Object.keys(result.errors).sort(), + [`${blankKey.id}.key`, `${blankValue.id}.value`, `${duplicate.id}.key`].sort()); +}); + +test('row IDs are unique', () => { + assert.notEqual(createNodeLabelRow().id, createNodeLabelRow().id); +}); + +test('status shape check accepts any well-formed affinity and rejects malformed structures', () => { + const term = { matchExpressions: [{ key: 'pool', operator: 'In', values: ['benchmark'] }], + matchFields: [{ key: 'metadata.name', operator: 'NotIn', values: ['node-1'] }] }; + for (const value of [{}, { requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [term] } }, + { preferredDuringSchedulingIgnoredDuringExecution: [{ weight: 50, preference: term }] }]) { + assert.equal(isNodeAffinity(value), true, JSON.stringify(value)); + } + const required = value => ({ requiredDuringSchedulingIgnoredDuringExecution: value }); + const expression = item => required({ nodeSelectorTerms: [{ matchExpressions: [item] }] }); + for (const value of [null, [], 'rules', required(null), required({}), required({ nodeSelectorTerms: [null] }), + expression({ key: 1, operator: 'In' }), expression({ key: 'pool', operator: 'In', values: 'x' }), + expression({ key: 'pool', operator: 'In', values: [1] }), + { preferredDuringSchedulingIgnoredDuringExecution: [{ weight: '50', preference: term }] }]) { + assert.equal(isNodeAffinity(value), false, JSON.stringify(value)); + } +}); diff --git a/tests/benchmarkNodeAffinityForm.test.tsx b/tests/benchmarkNodeAffinityForm.test.tsx new file mode 100644 index 0000000..b3417da --- /dev/null +++ b/tests/benchmarkNodeAffinityForm.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { BenchmarkForm } from '../src/BenchmarkForm'; +import { toCreateBenchmarkRunRequest } from '../src/benchmarkFormValidation'; +import type { CreateBenchmarkRunRequest } from '../src/benchmarkApi'; +import type { BenchmarkTarget } from '../src/benchmarkTarget'; + +afterEach(cleanup); + +const target: BenchmarkTarget = { k8sCluster: 'main', namespace: 'default', instance: 'pg-1' }; + +function setup(isSubmitting = false) { + const submissions: CreateBenchmarkRunRequest[] = []; + const onSubmit = (values: Parameters[0]) => { + const request = toCreateBenchmarkRunRequest(values, target); + if (request) submissions.push(request); + }; + const view = render(); + return { + submissions, + rerender: (pending: boolean) => view.rerender( + , + ), + }; +} + +const placement = () => screen.getByRole('button', { name: /Job placement/ }); +const affinitySwitch = () => screen.getByRole('switch', { name: 'Run on nodes with specific labels' }) as HTMLInputElement; +const enableAffinity = () => { fireEvent.click(placement()); fireEvent.click(affinitySwitch()); }; +const startButton = () => screen.getByRole('button', { name: /Start benchmark|Starting…/ }); +const submit = () => fireEvent.click(startButton()); +const input = (label: string) => screen.getByLabelText(label) as HTMLInputElement; +const change = (label: string, value: string) => fireEvent.change(input(label), { target: { value } }); + +describe('benchmark node affinity form', () => { + it('omits affinity for an ordinary run', () => { + const { submissions } = setup(); + submit(); + expect(submissions).toHaveLength(1); + expect(submissions[0]).not.toHaveProperty('nodeAffinity'); + }); + + it('submits all label rows as one required term', () => { + const { submissions } = setup(); + enableAffinity(); + expect(startButton().hasAttribute('disabled')).toBe(true); + change('Label key 1', 'pool'); + change('Label value 1', 'benchmark'); + fireEvent.click(screen.getByRole('button', { name: 'Add label' })); + change('Label key 2', 'disk'); + change('Label value 2', 'ssd'); + submit(); + expect(submissions).toHaveLength(1); + expect(submissions[0].nodeAffinity).toEqual({ + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: [{ matchExpressions: [ + { key: 'pool', operator: 'In', values: ['benchmark'] }, + { key: 'disk', operator: 'In', values: ['ssd'] }, + ] }] }, + }); + }); + + it('shows field errors only after the field is left, but always counts them', () => { + setup(); + enableAffinity(); + expect(screen.getByText('2 placement errors')).toBeTruthy(); + expect(screen.queryByText('Enter a label key.')).toBeNull(); + fireEvent.blur(input('Label key 1')); + expect(screen.getByText('Enter a label key.')).toBeTruthy(); + expect(screen.queryByText('Enter a label value.')).toBeNull(); + }); + + it('keeps each row\'s values and errors attached to it when an earlier row is removed', () => { + const { submissions } = setup(); + enableAffinity(); + change('Label key 1', 'pool'); + change('Label value 1', 'benchmark'); + fireEvent.click(screen.getByRole('button', { name: 'Add label' })); + change('Label key 2', 'disk'); + fireEvent.blur(input('Label value 2')); + expect(screen.getByText('Enter a label value.')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Remove label 1' })); + expect(input('Label key 1').value).toBe('disk'); + expect(screen.getByText('Enter a label value.')).toBeTruthy(); + change('Label value 1', 'ssd'); + submit(); + expect(submissions[0].nodeAffinity?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms) + .toEqual([{ matchExpressions: [{ key: 'disk', operator: 'In', values: ['ssd'] }] }]); + }); + + it('keeps entered rows when switched off and on again, and disables controls while submitting', () => { + const { submissions, rerender } = setup(); + enableAffinity(); + change('Label key 1', 'pool'); + fireEvent.click(affinitySwitch()); + submit(); + expect(submissions[0]).not.toHaveProperty('nodeAffinity'); + fireEvent.click(affinitySwitch()); + expect(input('Label key 1').value).toBe('pool'); + rerender(true); + expect(affinitySwitch().disabled).toBe(true); + expect(input('Label key 1').disabled).toBe(true); + }); +}); diff --git a/tests/benchmarkRunResult.test.mjs b/tests/benchmarkRunResult.test.mjs index 03f9984..d7dafaf 100644 --- a/tests/benchmarkRunResult.test.mjs +++ b/tests/benchmarkRunResult.test.mjs @@ -69,3 +69,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, /