Skip to content

Commit 1b0f7f7

Browse files
committed
fix(validator): tolerate all taints on Trainer/JobSet controller Deployments
The Kubeflow Trainer/JobSet controller-manager Deployments ship with no tolerations. On a cluster where every node pool carries a taint (e.g. an arch-tainted GPU pool plus a system pool GKE reserves for its own managed components once no untainted pool remains), the controllers have nowhere to schedule and installTrainer times out waiting for a Deployment that can never become Ready. applyControllerTolerations stamps a blanket tolerate-all onto the Trainer and JobSet controller-manager Deployments specifically (by name) when either has no existing tolerations; a Deployment that already declares tolerations, or any other Deployment in the manifest set, is left untouched. Scoping by name rather than by Kind alone matters here: this is called for every Deployment decoded from the installer's manifest set, and a future addition to that set must not silently inherit a blanket {operator: Exists} it never asked for. Extract the repeated "operator" toleration-key literal into keyOperator to satisfy golangci-lint's goconst threshold across the package. Signed-off-by: Mike Cook <micook@nvidia.com>
1 parent d5aad12 commit 1b0f7f7

5 files changed

Lines changed: 188 additions & 2 deletions

File tree

validators/performance/consts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const (
2121
versionV1alpha1 = "v1alpha1"
2222
versionV1beta1 = "v1beta1"
2323
keyName = "name"
24+
keyOperator = "operator"
2425
checkNameNCCLAllReduceBW = "nccl-all-reduce-bw"
2526

2627
// nodeJobName is the name of both the NCCL worker replicatedJob and its

validators/performance/inference_perf_constraint.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1850,7 +1850,7 @@ func tolerationsToUnstructured(tolerations []v1.Toleration) []interface{} {
18501850
tolList := make([]interface{}, 0, len(tolerations))
18511851
for _, t := range tolerations {
18521852
tolMap := map[string]interface{}{
1853-
"operator": string(t.Operator),
1853+
keyOperator: string(t.Operator),
18541854
}
18551855
if t.Key != "" {
18561856
tolMap["key"] = t.Key

validators/performance/nccl_all_reduce_bw_constraint.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1451,7 +1451,7 @@ func applyNCCLWorkerScheduling(obj *unstructured.Unstructured, nodeSelector map[
14511451
tolList := make([]interface{}, 0, len(tolerations))
14521452
for _, t := range tolerations {
14531453
tolMap := map[string]interface{}{
1454-
"operator": string(t.Operator),
1454+
keyOperator: string(t.Operator),
14551455
}
14561456
if t.Key != "" {
14571457
tolMap["key"] = t.Key

validators/performance/trainer_lifecycle.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ const (
6767
// trainerControllerDeployment is the Deployment name for the Trainer controller-manager.
6868
trainerControllerDeployment = "kubeflow-trainer-controller-manager"
6969

70+
// jobSetControllerDeployment is the JobSet controller-manager Deployment name
71+
// emitted by this package's kustomize overlay (see jobSetNameLabel for why
72+
// the Helm chart's release-derived name doesn't apply here).
73+
jobSetControllerDeployment = "jobset-controller-manager"
74+
7075
// trainerControllerService is the Service fronting the controller-manager's
7176
// webhook port. Without it the admission webhooks have no endpoints and every
7277
// TrainJob create is rejected.
@@ -158,6 +163,43 @@ const (
158163
jobSetPromotedImageRepo = "registry.k8s.io/jobset/jobset"
159164
)
160165

166+
// controllerTolerateAll lets a Trainer/JobSet controller-manager Deployment
167+
// schedule on any node pool, regardless of taints.
168+
var controllerTolerateAll = []interface{}{
169+
map[string]interface{}{keyOperator: "Exists"},
170+
}
171+
172+
// applyControllerTolerations stamps controllerTolerateAll onto the Trainer and
173+
// JobSet controller-manager Deployments' pod template, unless one already
174+
// declares tolerations. Scoped to those two names so an unrelated Deployment
175+
// in the manifest set never gets a blanket toleration it didn't ask for.
176+
func applyControllerTolerations(obj *unstructured.Unstructured) error {
177+
if obj.GroupVersionKind().Kind != "Deployment" {
178+
return nil
179+
}
180+
switch obj.GetName() {
181+
case trainerControllerDeployment, jobSetControllerDeployment:
182+
default:
183+
return nil
184+
}
185+
186+
if existing, found, err := unstructured.NestedSlice(obj.Object, "spec", "template", "spec", "tolerations"); err != nil {
187+
return aicrErrors.Wrap(aicrErrors.ErrCodeInternal,
188+
fmt.Sprintf("failed to read tolerations from Deployment %q", obj.GetName()), err)
189+
} else if found && len(existing) > 0 {
190+
return nil
191+
}
192+
193+
podSpec, found := nestedMap(obj.Object, "spec", "template", "spec")
194+
if !found {
195+
return aicrErrors.New(aicrErrors.ErrCodeInternal,
196+
fmt.Sprintf("pod spec not found in Deployment %q", obj.GetName()))
197+
}
198+
podSpec["tolerations"] = controllerTolerateAll
199+
slog.Info("Applying blanket toleration to controller Deployment", "name", obj.GetName())
200+
return nil
201+
}
202+
161203
// GVRs for the objects the Trainer lifecycle probes and waits on.
162204
var (
163205
trainerCRDGVR = schema.GroupVersionResource{
@@ -806,6 +848,9 @@ func decodeTrainerObjects(resources []*resource.Resource) ([]*unstructured.Unstr
806848
if obj.GroupVersionKind().Kind == "" {
807849
continue
808850
}
851+
if tolErr := applyControllerTolerations(obj); tolErr != nil {
852+
return nil, tolErr
853+
}
809854
objs = append(objs, obj)
810855
}
811856
return objs, nil

validators/performance/trainer_lifecycle_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ package main
1717
import (
1818
"strings"
1919
"testing"
20+
21+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2022
)
2123

2224
func TestRewriteJobSetStagingImage(t *testing.T) {
@@ -76,3 +78,141 @@ func TestRewriteJobSetStagingImage_PreservesTag(t *testing.T) {
7678
t.Errorf("got %q, want %q", got, want)
7779
}
7880
}
81+
82+
// deploymentFixture returns a minimal unstructured Deployment, optionally with an
83+
// existing tolerations list, for exercising applyControllerTolerations.
84+
func deploymentFixture(name string, existingTolerations []interface{}) *unstructured.Unstructured {
85+
podSpec := map[string]interface{}{
86+
"containers": []interface{}{
87+
map[string]interface{}{"name": "manager", "image": "example/manager:latest"},
88+
},
89+
}
90+
if existingTolerations != nil {
91+
podSpec["tolerations"] = existingTolerations
92+
}
93+
return &unstructured.Unstructured{Object: map[string]interface{}{
94+
"apiVersion": "apps/v1",
95+
"kind": "Deployment",
96+
"metadata": map[string]interface{}{"name": name},
97+
"spec": map[string]interface{}{
98+
"template": map[string]interface{}{
99+
"spec": podSpec,
100+
},
101+
},
102+
}}
103+
}
104+
105+
// TestApplyControllerTolerations covers both controller names, the two
106+
// mutation-failure paths, and that an unrelated Deployment is left untouched.
107+
func TestApplyControllerTolerations(t *testing.T) {
108+
tests := []struct {
109+
name string
110+
obj *unstructured.Unstructured
111+
wantErr bool
112+
// wantTolerations is checked only when wantErr is false. nil means "the
113+
// tolerations field must not be present at all" (untouched, not merely
114+
// empty).
115+
wantTolerations []interface{}
116+
}{
117+
{
118+
name: "Trainer controller Deployment with no tolerations gets tolerate-all",
119+
obj: deploymentFixture(trainerControllerDeployment, nil),
120+
wantTolerations: []interface{}{
121+
map[string]interface{}{"operator": "Exists"},
122+
},
123+
},
124+
{
125+
name: "JobSet controller Deployment with no tolerations gets tolerate-all",
126+
obj: deploymentFixture(jobSetControllerDeployment, nil),
127+
wantTolerations: []interface{}{
128+
map[string]interface{}{"operator": "Exists"},
129+
},
130+
},
131+
{
132+
name: "Deployment with existing tolerations is left untouched",
133+
obj: deploymentFixture(trainerControllerDeployment, []interface{}{
134+
map[string]interface{}{"key": "dedicated", "operator": "Equal", "value": "trainer", "effect": "NoSchedule"},
135+
}),
136+
wantTolerations: []interface{}{
137+
map[string]interface{}{"key": "dedicated", "operator": "Equal", "value": "trainer", "effect": "NoSchedule"},
138+
},
139+
},
140+
{
141+
name: "non-controller Deployment is left untouched",
142+
obj: deploymentFixture("some-other-deployment", nil),
143+
wantTolerations: nil,
144+
},
145+
{
146+
name: "non-Deployment resource is left untouched",
147+
obj: &unstructured.Unstructured{Object: map[string]interface{}{
148+
"apiVersion": "v1",
149+
"kind": "Service",
150+
"metadata": map[string]interface{}{"name": trainerControllerDeployment},
151+
"spec": map[string]interface{}{},
152+
}},
153+
wantTolerations: nil,
154+
},
155+
{
156+
name: "missing pod spec fails closed",
157+
obj: &unstructured.Unstructured{Object: map[string]interface{}{
158+
"apiVersion": "apps/v1",
159+
"kind": "Deployment",
160+
"metadata": map[string]interface{}{"name": trainerControllerDeployment},
161+
"spec": map[string]interface{}{},
162+
}},
163+
wantErr: true,
164+
},
165+
{
166+
name: "malformed tolerations field fails closed",
167+
obj: &unstructured.Unstructured{Object: map[string]interface{}{
168+
"apiVersion": "apps/v1",
169+
"kind": "Deployment",
170+
"metadata": map[string]interface{}{"name": trainerControllerDeployment},
171+
"spec": map[string]interface{}{
172+
"template": map[string]interface{}{
173+
"spec": map[string]interface{}{
174+
// A string, not a slice: NestedSlice's type assertion fails.
175+
"tolerations": "not-a-slice",
176+
},
177+
},
178+
},
179+
}},
180+
wantErr: true,
181+
},
182+
}
183+
184+
for _, tt := range tests {
185+
t.Run(tt.name, func(t *testing.T) {
186+
err := applyControllerTolerations(tt.obj)
187+
if (err != nil) != tt.wantErr {
188+
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
189+
}
190+
if tt.wantErr {
191+
return
192+
}
193+
194+
got, found, _ := unstructured.NestedSlice(tt.obj.Object, "spec", "template", "spec", "tolerations")
195+
if tt.wantTolerations == nil {
196+
if found {
197+
t.Errorf("expected no tolerations field, got %v", got)
198+
}
199+
return
200+
}
201+
if !found {
202+
t.Fatalf("expected tolerations %v, found none", tt.wantTolerations)
203+
}
204+
if len(got) != len(tt.wantTolerations) {
205+
t.Fatalf("got %d toleration(s) %v, want %d %v", len(got), got, len(tt.wantTolerations), tt.wantTolerations)
206+
}
207+
for i := range got {
208+
gotTol, _ := got[i].(map[string]interface{})
209+
wantTol, _ := tt.wantTolerations[i].(map[string]interface{})
210+
for k, v := range wantTol {
211+
if gotTol[k] != v {
212+
t.Errorf("toleration[%d][%q] = %v, want %v", i, k, gotTol[k], v)
213+
}
214+
}
215+
}
216+
})
217+
}
218+
}

0 commit comments

Comments
 (0)