Skip to content

Commit acfb54c

Browse files
committed
fix(validator): decide the Trainer lifecycle from the recipe, not live cluster state
The NCCL benchmark probed the cluster for a Kubeflow Trainer and installed one when it found none. That made the same recipe behave differently depending on what happened to be installed, and — more importantly — it masked deployment failures: a recipe that ships the kubeflow-trainer component but whose Trainer failed to deploy got an ephemeral fixture installed over the gap, and the benchmark then reported a passing bandwidth result for a cluster that cannot run TrainJobs at all. ensureTrainerInstalled now takes the recipe's declaration and keys on it: - declared and present -> use the delivered installation, unchanged - declared but missing -> fail with ErrCodeUnavailable naming the component - not declared -> install an ephemeral fixture, as before The third case is the current behavior and stays that way: the recipe never promised a Trainer, so there is nothing to mask. Applicability is untouched. It is already recipe-derived through supportedNCCLCombinations and the nccl-benchmark-profile constraint, and this change deliberately does not gate on RecipeDeclares — the nccl-all-reduce-bw constraints are declared on base overlays that do not carry kubeflow-trainer, so gating applicability on the component would skip the benchmark on every recipe that actually declares it. Refs #2297 Signed-off-by: Yuan Chen <yuanchen97@gmail.com>
1 parent 90a7f41 commit acfb54c

3 files changed

Lines changed: 86 additions & 10 deletions

File tree

validators/performance/nccl_all_reduce_bw_constraint.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -478,10 +478,14 @@ func runNCCLTrainJob(ctx *validators.Context, gpuConfig *gpuConfiguration,
478478

479479
dynamicClient := ctx.DynamicClient
480480

481-
// Ensure a usable Kubeflow Trainer. A complete installation already on the
482-
// cluster is left alone and reports no resources; anything we install is ours
483-
// to clean up after the test completes.
484-
installedResources, err := ensureTrainerInstalled(ctx.Ctx, dynamicClient, ctx.Clientset.Discovery())
481+
// Ensure a usable Kubeflow Trainer. Whether an incomplete installation is a
482+
// failure or something to install over is decided by the recipe, not by what
483+
// happens to be on the cluster: a recipe that ships the component must have a
484+
// working one, while a recipe that does not gets an ephemeral fixture. Anything
485+
// we install is ours to clean up after the test completes.
486+
recipeDeclaresTrainer := validators.RecipeDeclares(ctx, kubeflowTrainerComponent)
487+
installedResources, err := ensureTrainerInstalled(ctx.Ctx, dynamicClient,
488+
ctx.Clientset.Discovery(), recipeDeclaresTrainer)
485489
if err != nil {
486490
return "", err
487491
}

validators/performance/trainer_ensure_test.go

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import (
3333
func TestEnsureTrainerInstalled_CompleteInstallIsLeftAlone(t *testing.T) {
3434
client := newTrainerFakeClient(completeTrainerInstall()...)
3535

36-
refs, err := ensureTrainerInstalled(context.Background(), client, nil)
36+
refs, err := ensureTrainerInstalled(context.Background(), client, nil, false)
3737
if err != nil {
3838
t.Fatalf("unexpected error: %v", err)
3939
}
@@ -81,7 +81,7 @@ func TestEnsureTrainerInstalled_WaitsOnDiscoveredControllerName(t *testing.T) {
8181
return false, nil, nil
8282
})
8383

84-
refs, err := ensureTrainerInstalled(ctx, client, nil)
84+
refs, err := ensureTrainerInstalled(ctx, client, nil, false)
8585
if err != nil {
8686
t.Fatalf("unexpected error waiting on the discovered controller %q (polled %v): %v",
8787
releaseDerivedName, polled, err)
@@ -126,7 +126,7 @@ func TestEnsureTrainerInstalled_WaitsForPreexistingController(t *testing.T) {
126126
})
127127
defer cancel()
128128

129-
refs, err := ensureTrainerInstalled(ctx, client, nil)
129+
refs, err := ensureTrainerInstalled(ctx, client, nil, false)
130130
if err == nil {
131131
t.Fatal("expected a not-ready pre-existing controller to fail, got nil error")
132132
}
@@ -156,7 +156,7 @@ func TestEnsureTrainerInstalled_RefusesToInstallOverForeignNamespace(t *testing.
156156
trainerValidatingWebhookName, "kubeflow"),
157157
)
158158

159-
refs, err := ensureTrainerInstalled(context.Background(), client, nil)
159+
refs, err := ensureTrainerInstalled(context.Background(), client, nil, false)
160160
if err == nil {
161161
t.Fatal("expected the installer to refuse installing over an installation in another namespace")
162162
}
@@ -180,7 +180,7 @@ func TestEnsureTrainerInstalled_PreservesProbeErrorCode(t *testing.T) {
180180
return true, nil, apierrors.NewServiceUnavailable("apiserver is down")
181181
})
182182

183-
_, err := ensureTrainerInstalled(context.Background(), client, nil)
183+
_, err := ensureTrainerInstalled(context.Background(), client, nil, false)
184184
if err == nil {
185185
t.Fatal("expected error, got nil")
186186
}
@@ -234,3 +234,45 @@ func TestFoldCleanupError_PreservesCleanupCode(t *testing.T) {
234234
t.Errorf("cleanup error code was flattened: %v", got)
235235
}
236236
}
237+
238+
// TestEnsureTrainerInstalled_RecipeDeclaresButMissing verifies the benchmark fails
239+
// rather than self-installing when the recipe ships Kubeflow Trainer and no complete
240+
// installation is found.
241+
//
242+
// This is the whole point of keying the decision on the recipe: installing an
243+
// ephemeral Trainer here would let the benchmark report a passing bandwidth result
244+
// for a cluster whose delivered Trainer is broken — the deployment failure the
245+
// recipe's own component promised would be masked by the validator working around
246+
// it.
247+
func TestEnsureTrainerInstalled_RecipeDeclaresButMissing(t *testing.T) {
248+
// An empty cluster: nothing is installed, which is what a failed deployment of
249+
// the kubeflow-trainer component looks like to the probe.
250+
client := newTrainerFakeClient()
251+
252+
refs, err := ensureTrainerInstalled(context.Background(), client, nil, true)
253+
if err == nil {
254+
t.Fatal("expected an error when the recipe declares kubeflow-trainer but none is installed")
255+
}
256+
if len(refs) != 0 {
257+
t.Errorf("refs = %d, want 0 (nothing may be installed on the failure path)", len(refs))
258+
}
259+
if !strings.Contains(err.Error(), kubeflowTrainerComponent) {
260+
t.Errorf("error %q does not name the %s component, so an operator cannot tell which "+
261+
"component failed to deploy", err, kubeflowTrainerComponent)
262+
}
263+
}
264+
265+
// TestEnsureTrainerInstalled_RecipeDeclaresAndPresent verifies the delivered
266+
// installation is used as-is: it is not reinstalled, and it is not claimed for
267+
// cleanup, because the recipe owns it rather than the benchmark.
268+
func TestEnsureTrainerInstalled_RecipeDeclaresAndPresent(t *testing.T) {
269+
client := newTrainerFakeClient(completeTrainerInstall()...)
270+
271+
refs, err := ensureTrainerInstalled(context.Background(), client, nil, true)
272+
if err != nil {
273+
t.Fatalf("unexpected error: %v", err)
274+
}
275+
if len(refs) != 0 {
276+
t.Errorf("refs = %d, want 0 (a recipe-delivered Trainer must never be claimed for cleanup)", len(refs))
277+
}
278+
}

validators/performance/trainer_lifecycle.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ const (
111111
// overwriting a generically-named admission configuration another operator owns.
112112
trainerWebhookSuffix = ".trainer.kubeflow.org"
113113

114+
// kubeflowTrainerComponent is the registry name of the Kubeflow Trainer
115+
// component. A recipe declaring it is promising a Trainer installation, which
116+
// is what ensureTrainerInstalled keys its behavior on.
117+
kubeflowTrainerComponent = "kubeflow-trainer"
118+
114119
// jobSetCRDName identifies the JobSet dependency. TrainJobs run as JobSets, so
115120
// a Trainer whose JobSet controller never becomes ready fails opaquely later.
116121
jobSetCRDName = "jobsets.jobset.x-k8s.io"
@@ -431,8 +436,22 @@ func trainerResourceClient(dynamicClient dynamic.Interface,
431436
// and returns the resources it created, so the caller can delete them when the run
432437
// finishes. A complete pre-existing installation is left alone and reported as no
433438
// resources, so the benchmark never deletes a Trainer it does not own.
439+
//
440+
// recipeDeclaresTrainer says whether the recipe under validation ships Kubeflow
441+
// Trainer as a delivered component, and it decides what an incomplete installation
442+
// means:
443+
//
444+
// - declared, and present: use the delivered installation.
445+
// - declared, but missing or incomplete: fail. Self-installing here would mask a
446+
// broken deployment of a component the recipe promised, and the benchmark would
447+
// then report a passing result for a cluster that cannot run TrainJobs at all.
448+
// - not declared: install an ephemeral fixture and tear it down, as before. The
449+
// recipe never claimed a Trainer, so there is nothing to mask.
450+
//
451+
// This is deliberately keyed on the recipe rather than on live cluster state, so the
452+
// same recipe behaves the same way regardless of what happens to be installed.
434453
func ensureTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface,
435-
discoveryClient discovery.DiscoveryInterface) ([]trainerResourceRef, error) {
454+
discoveryClient discovery.DiscoveryInterface, recipeDeclaresTrainer bool) ([]trainerResourceRef, error) {
436455

437456
install, installed, err := isTrainerInstalled(ctx, dynamicClient)
438457
if err != nil {
@@ -445,6 +464,17 @@ func ensureTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface
445464
}
446465

447466
if !installed {
467+
// The recipe ships Kubeflow Trainer, so a missing or incomplete installation
468+
// is a deployment failure, not something to paper over. Installing our own
469+
// here would produce a passing benchmark for a cluster whose delivered
470+
// Trainer is broken.
471+
if recipeDeclaresTrainer {
472+
return nil, aicrErrors.New(aicrErrors.ErrCodeUnavailable, fmt.Sprintf(
473+
"the recipe declares the %s component but no complete Kubeflow Trainer "+
474+
"installation was found; the benchmark will not self-install over a "+
475+
"delivered component that failed to deploy", kubeflowTrainerComponent))
476+
}
477+
448478
// Before applying anything, check for a live installation somewhere else.
449479
// Kustomize applies CRDs and RBAC before webhook configurations, so by the
450480
// time the admission-config ownership guard could fire, a shared-name

0 commit comments

Comments
 (0)