Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions pkg/defaults/timeouts.go
Original file line number Diff line number Diff line change
Expand Up @@ -666,8 +666,15 @@ const (
TrainerCRDEstablishedTimeout = 2 * time.Minute

// TrainerControllerReadyTimeout is the time to wait for the Kubeflow Trainer
// controller-manager Deployment to have at least one ready replica after installation.
TrainerControllerReadyTimeout = 2 * time.Minute
// controller-manager Deployment to have at least one ready replica after
// installation. Widened from 2m to 3m: on cold start the cert-controller
// sidecar's webhook-cert get-or-create can race a not-yet-synced informer
// cache, producing a resourceVersion conflict that the sidecar's own
// reconcile loop retries and self-heals from unassisted. This is expected
// behavior under cert-controller's optimistic-concurrency retry, not a
// defect in Trainer or in this validator — but each retry adds latency
// that could otherwise push first-ready past a tighter budget.
TrainerControllerReadyTimeout = 3 * time.Minute

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — 2m→3m rationale isn't captured in-code

The value changed 2m→3m but the doc comment still describes only "at least one ready replica after installation." The cert-controller cold-start / retry reasoning lives only in the PR body and is lost on squash-merge.

Fix: Fold a one-line why into the comment so the next engineer doesn't tune it back down and reintroduce the flake.


// TrainerInstallPollInterval is the sleep between checks that a
// recipe-declared Kubeflow Trainer installation has become complete. The
Expand Down
1 change: 1 addition & 0 deletions validators/performance/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const (
versionV1alpha1 = "v1alpha1"
versionV1beta1 = "v1beta1"
keyName = "name"
keyOperator = "operator"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — keyOperator const wasn't actually required by goconst

The PR justifies this const as needed "to satisfy golangci-lint's goconst." But .golangci.yaml sets goconst: {min-occurrences: 3, ignore-tests: true}, and pre-PR there were exactly 2 non-test occurrences of "operator" (the two source files this PR edits) — goconst was already passing.

Fix: None required — the extraction is a fine readability improvement; just noting the stated CI justification is inaccurate.

checkNameNCCLAllReduceBW = "nccl-all-reduce-bw"

// nodeJobName is the name of both the NCCL worker replicatedJob and its
Expand Down
2 changes: 1 addition & 1 deletion validators/performance/inference_perf_constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -1823,7 +1823,7 @@ func tolerationsToUnstructured(tolerations []v1.Toleration) []any {
tolList := make([]any, 0, len(tolerations))
for _, t := range tolerations {
tolMap := map[string]any{
"operator": string(t.Operator),
keyOperator: string(t.Operator),
}
if t.Key != "" {
tolMap["key"] = t.Key
Expand Down
4 changes: 2 additions & 2 deletions validators/performance/nccl_all_reduce_bw_constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -1451,7 +1451,7 @@ func applyNCCLWorkerScheduling(obj *unstructured.Unstructured, nodeSelector map[
tolList := make([]any, 0, len(tolerations))
for _, t := range tolerations {
tolMap := map[string]any{
"operator": string(t.Operator),
keyOperator: string(t.Operator),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — keyOperator output key unpinned by tests

The tolerationsToUnstructured / applyNCCLWorkerScheduling tests assert key/value/effect but never read tol["operator"], so the const-keyed output key is compiler-checked but not test-pinned.

Fix: Add tol["operator"] to the existing key/value/effect assertions to harden the refactor.

}
if t.Key != "" {
tolMap["key"] = t.Key
Expand Down Expand Up @@ -1479,7 +1479,7 @@ func applyNCCLWorkerScheduling(obj *unstructured.Unstructured, nodeSelector map[
return unstructured.SetNestedSlice(obj.Object, replicatedJobs, "spec", "template", "spec", "replicatedJobs")
}

// nestedMap navigates a chain of string keys through nested map[string]interface{} values.
// nestedMap navigates a chain of string keys through nested map[string]any values.
// Returns the target map and true if found, nil and false otherwise.
func nestedMap(m map[string]any, keys ...string) (map[string]any, bool) {
current := m
Expand Down
8 changes: 4 additions & 4 deletions validators/performance/nccl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ func TestApplyNCCLWorkerScheduling_Tolerations(t *testing.T) {
t.Fatalf("tolerations count = %d, want 1", len(tolsRaw))
}
tol, _ := tolsRaw[0].(map[string]any)
if tol["key"] != "gpu-type" || tol["value"] != "h100" || tol["effect"] != "NoSchedule" {
t.Errorf("toleration = %v, want gpu-type=h100:NoSchedule", tol)
if tol["key"] != "gpu-type" || tol["value"] != "h100" || tol["effect"] != "NoSchedule" || tol["operator"] != "Equal" {
t.Errorf("toleration = %v, want gpu-type=h100:NoSchedule operator=Equal", tol)
}
}
}
Expand Down Expand Up @@ -209,8 +209,8 @@ func TestApplyNCCLWorkerScheduling_Both(t *testing.T) {
t.Fatalf("tolerations count = %d, want 1", len(tolsRaw))
}
tol, _ := tolsRaw[0].(map[string]any)
if tol["key"] != "custom-taint" || tol["value"] != "true" || tol["effect"] != "NoSchedule" {
t.Errorf("toleration = %v, want custom-taint=true:NoSchedule", tol)
if tol["key"] != "custom-taint" || tol["value"] != "true" || tol["effect"] != "NoSchedule" || tol["operator"] != "Equal" {
t.Errorf("toleration = %v, want custom-taint=true:NoSchedule operator=Equal", tol)
}
}
}
Expand Down
97 changes: 97 additions & 0 deletions validators/performance/trainer_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ import (
"strings"
"time"

"github.com/NVIDIA/aicr/pkg/component"
"github.com/NVIDIA/aicr/pkg/defaults"
aicrErrors "github.com/NVIDIA/aicr/pkg/errors"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -67,6 +69,11 @@ const (
// trainerControllerDeployment is the Deployment name for the Trainer controller-manager.
trainerControllerDeployment = "kubeflow-trainer-controller-manager"

// jobSetControllerDeployment is the JobSet controller-manager Deployment name
// emitted by this package's kustomize overlay (see jobSetNameLabel for why
// the Helm chart's release-derived name doesn't apply here).
jobSetControllerDeployment = "jobset-controller-manager"

// trainerControllerService is the Service fronting the controller-manager's
// webhook port. Without it the admission webhooks have no endpoints and every
// TrainJob create is rejected.
Expand Down Expand Up @@ -159,6 +166,76 @@ const (
jobSetPromotedImageRepo = "registry.k8s.io/jobset/jobset"
)

// controllerTolerateAll lets a Trainer/JobSet controller-manager Deployment
// schedule on any node pool, regardless of taints. Built through
// component.TolerationsToPodSpec, the same converter used for Helm-values
// toleration overrides, so there is one canonical place that knows the
// toleration-to-map shape.
var controllerTolerateAll = tolerationsToAnySlice(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — controllerTolerateAll is aliased by reference into both Deployments (latent)

controllerTolerateAll is a package-level []any holding one shared map[string]any, assigned by reference to podSpec["tolerations"] for both the Trainer and JobSet Deployments. No live bug today — both apply paths obj.DeepCopy() before Create/Update and nothing mutates the tolerations in place — but a future in-place edit of one controller's tolerations would silently corrupt the other and the process-wide global.

Fix: A per-call copy inside applyControllerTolerations is cheap hygiene, and a two-Deployment isolation test would pin it.

component.TolerationsToPodSpec([]corev1.Toleration{{Operator: corev1.TolerationOpExists}}),
)

// tolerationsToAnySlice widens []map[string]any to []any: unstructured pod
// specs (podSpec["tolerations"]) must hold []any, not []map[string]any, to
// match how NestedSlice reads and how JSON round-tripping serializes it.
func tolerationsToAnySlice(tolerations []map[string]any) []any {
result := make([]any, len(tolerations))
for i, t := range tolerations {
result[i] = t
}
return result
}

// cloneControllerTolerateAll returns a fresh copy of controllerTolerateAll.
// It is stamped onto both the Trainer and JobSet Deployments, so a per-call
// copy keeps a future in-place edit of one controller's tolerations from
// silently corrupting the other Deployment's live object, or the shared
// process-wide global itself.
func cloneControllerTolerateAll() []any {
cloned := make([]any, len(controllerTolerateAll))
for i, t := range controllerTolerateAll {
m, _ := t.(map[string]any)
clonedMap := make(map[string]any, len(m))
for k, v := range m {
clonedMap[k] = v
}
cloned[i] = clonedMap
}
return cloned
}

// applyControllerTolerations stamps controllerTolerateAll onto the Trainer and
// JobSet controller-manager Deployments' pod template, unless one already
// declares tolerations. Scoped to those two names so an unrelated Deployment
// in the manifest set never gets a blanket toleration it didn't ask for.
func applyControllerTolerations(obj *unstructured.Unstructured) error {
if gvk := obj.GroupVersionKind(); gvk.Kind != "Deployment" || gvk.Group != "apps" {
return nil
}
switch obj.GetName() {
case trainerControllerDeployment, jobSetControllerDeployment:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — Name-based match silently no-ops if a future archive bump renames a controller

The two controller Deployments are matched by exact name in this switch. The Kubeflow Trainer archive is downloaded at runtime, so a future pinned-version bump (or the separately-managed JobSet overlay variant) that renames the emitted Deployment makes both cases miss — the function returns nil with no log or error, and that controller reverts to un-schedulable on all-tainted clusters. It resurfaces as a bare (now 3-minute) readiness timeout, precisely the failure the label-based readiness lookup was built to avoid.

Blast radius: Self-install path; the JobSet/Trainer controller silently loses its toleration on a future upstream rename and the benchmark fails at readiness wait rather than with a clear signal.

Fix: Emit a warn-log when a controller-shaped Deployment isn't matched, or add a decode-time assertion that both expected controller names appeared among the decoded objects.

default:
return nil
}

if existing, found, err := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "tolerations"); err != nil {
return aicrErrors.Wrap(aicrErrors.ErrCodeInternal,
fmt.Sprintf("failed to read tolerations from Deployment %q", obj.GetName()), err)
} else if found && len(existing) > 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — "already tolerated → skip" branch returns silently

The apply branch logs slog.Info("Applying blanket toleration…"), but this found && len(existing) > 0 { return nil } skip branch returns with no log. An operator debugging why a controller is still Pending on a tainted pool gets no signal that the blanket toleration was deliberately withheld because the manifest already had its own.

Fix: Add a slog.Debug("…already declares tolerations; leaving untouched", "name", obj.GetName()) before the early return.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — present-but-empty tolerations boundary untested

The guard found && len(existing) > 0 means a Deployment shipping tolerations: [] falls through and gets stamped (the desired behavior). The test covers only nil and non-empty.

Fix: Add a deploymentFixture(trainerControllerDeployment, []any{}) case pinning this boundary against a future refactor that flips the guard to found alone.

slog.Debug("Controller Deployment already declares tolerations; leaving untouched", "name", obj.GetName())
return nil
}

podSpec, found := nestedMap(obj.Object, "spec", "template", "spec")
if !found {
return aicrErrors.New(aicrErrors.ErrCodeInternal,
fmt.Sprintf("pod spec not found in Deployment %q", obj.GetName()))
}
podSpec["tolerations"] = cloneControllerTolerateAll()
slog.Info("Applying blanket toleration to controller Deployment", "name", obj.GetName(), "namespace", obj.GetNamespace())
return nil
}

// GVRs for the objects the Trainer lifecycle probes and waits on.
var (
trainerCRDGVR = schema.GroupVersionResource{
Expand Down Expand Up @@ -791,6 +868,7 @@ func installTrainer(ctx context.Context, dynamicClient dynamic.Interface, discov
// before the first apply so a malformed manifest cannot leave a partial install.
func decodeTrainerObjects(resources []*resource.Resource) ([]*unstructured.Unstructured, error) {
objs := make([]*unstructured.Unstructured, 0, len(resources))
seenControllers := make(map[string]bool, 2)
for _, res := range resources {
// Convert to unstructured via YAML round-trip (guarantees plain Go types).
yamlBytes, err := res.AsYAML()
Expand All @@ -807,8 +885,27 @@ func decodeTrainerObjects(resources []*resource.Resource) ([]*unstructured.Unstr
if obj.GroupVersionKind().Kind == "" {
continue
}
if tolErr := applyControllerTolerations(obj); tolErr != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — decodeTrainerObjects has no end-to-end test

applyControllerTolerations is unit-tested directly (both error paths included), but its only call site — decodeTrainerObjects — has no test. The wiring (toleration actually lands on returned objects, decode-error propagation, the Kind=="" skip ordering just above this call) is unverified.

Fix: Feed decodeTrainerObjects a small fixture with a controller Deployment + an unrelated object and assert the toleration lands only on the controller (and that a malformed resource yields a non-nil error).

return nil, tolErr
}
if obj.GroupVersionKind().Kind == "Deployment" {
seenControllers[obj.GetName()] = true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — seenControllers warn-loop key not apps-group scoped (inert asymmetry)

seenControllers[obj.GetName()] records any Kind=="Deployment" regardless of group, whereas applyControllerTolerations now also gates on gvk.Group=="apps". A non-apps Deployment-kind named like a controller would mark the name "seen" and suppress the warn while not receiving the toleration — the exact silent-miss the warn guards.

Blast radius: Unreachable in the fixed upstream kustomize set; purely defensive.

Fix: Optionally gate the seenControllers write on gvk.Group=="apps" too, to match the apply gate.

}
objs = append(objs, obj)
}

// A future Trainer archive bump (or a JobSet overlay variant) that renames
// either controller Deployment makes applyControllerTolerations's name
// switch miss it silently, reverting that controller to unschedulable on
// an all-tainted cluster. Surface the mismatch here instead of letting it
// resurface only as a bare readiness timeout downstream.
for _, name := range []string{trainerControllerDeployment, jobSetControllerDeployment} {
if !seenControllers[name] {
slog.Warn("Expected controller Deployment not found in Trainer manifest set; "+
"it will not receive the blanket toleration and may be unschedulable on all-tainted clusters",
"deployment", name)
}
}
return objs, nil
}

Expand Down
Loading
Loading