-
Notifications
You must be signed in to change notification settings - Fork 94
fix(validator): tolerate taints, widen Trainer readiness timeout #2445
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b734267
d9f20d0
efd4532
a150d8c
e40070b
b6ed346
71c1d75
f744ce2
7a8f40e
bdad251
4ae436a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ const ( | |
| versionV1alpha1 = "v1alpha1" | ||
| versionV1beta1 = "v1beta1" | ||
| keyName = "name" | ||
| keyOperator = "operator" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — keyOperator output key unpinned by tests The Fix: Add |
||
| } | ||
| if t.Key != "" { | ||
| tolMap["key"] = t.Key | ||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -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. | ||
|
|
@@ -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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — controllerTolerateAll is aliased by reference into both Deployments (latent)
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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — "already tolerated → skip" branch returns silently The apply branch logs Fix: Add a
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — present-but-empty tolerations boundary untested The guard Fix: Add a |
||
| 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{ | ||
|
|
@@ -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() | ||
|
|
@@ -807,8 +885,27 @@ func decodeTrainerObjects(resources []*resource.Resource) ([]*unstructured.Unstr | |
| if obj.GroupVersionKind().Kind == "" { | ||
| continue | ||
| } | ||
| if tolErr := applyControllerTolerations(obj); tolErr != nil { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — decodeTrainerObjects has no end-to-end test
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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — seenControllers warn-loop key not apps-group scoped (inert asymmetry)
Blast radius: Unreachable in the fixed upstream kustomize set; purely defensive. Fix: Optionally gate the |
||
| } | ||
| 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 | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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.