@@ -261,16 +261,19 @@ func isTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface) (t
261261 return install , false , nil
262262 }
263263
264- ok , err := hasTrainerWebhook (ctx , dynamicClient ,
264+ reason , ok , err := hasTrainerWebhook (ctx , dynamicClient ,
265265 trainerMutatingWebhookGVR , trainerMutatingWebhookConfig , trainerMutatingWebhookName )
266266 if err != nil {
267267 return trainerInstall {}, false , err
268268 }
269269 if ! ok {
270- slog .Info ("Kubeflow Trainer incomplete: admission webhook missing" ,
271- "configuration" , trainerMutatingWebhookConfig , "webhook" , trainerMutatingWebhookName )
272- return trainerInstall {Incomplete : fmt .Sprintf (
273- "admission configuration %q is missing" , trainerMutatingWebhookConfig )}, false , nil
270+ // Carry the specific reason out rather than reporting every failure as a
271+ // missing configuration: "create it" and "something else owns this name"
272+ // are different jobs for whoever reads the verdict.
273+ slog .Info ("Kubeflow Trainer incomplete: mutating admission webhook unusable" ,
274+ "configuration" , trainerMutatingWebhookConfig , "webhook" , trainerMutatingWebhookName ,
275+ "reason" , reason )
276+ return trainerInstall {Incomplete : reason }, false , nil
274277 }
275278
276279 // The controller Deployment is found by label: its name is release-derived on
@@ -418,17 +421,28 @@ func trainerAPIErrorCode(err error) aicrErrors.ErrorCode {
418421// serves the given Trainer webhook. The name check matters because the upstream
419422// manifests use generic, unprefixed configuration names that another operator on
420423// the cluster may already own.
424+ //
425+ // When the answer is no it returns the reason, because the two ways to get there
426+ // need different remedies: a configuration that is absent has to be created, while
427+ // one that exists but serves someone else's webhook has to be reconciled with
428+ // whatever already owns that name. Reporting both as "missing" — which is what
429+ // flattening this to a bare false did — sends an operator to create an object that
430+ // is already on the cluster. The validating-webhook path in discoverTrainerInstall
431+ // draws the same distinction.
421432func hasTrainerWebhook (ctx context.Context , dynamicClient dynamic.Interface ,
422- gvr schema.GroupVersionResource , configName , webhookName string ) (bool , error ) {
433+ gvr schema.GroupVersionResource , configName , webhookName string ) (string , bool , error ) {
423434
424435 obj , found , err := getTrainerObject (ctx , dynamicClient , gvr , "" , configName )
425- if err != nil || ! found {
426- return false , err
436+ if err != nil {
437+ return "" , false , err
438+ }
439+ if ! found {
440+ return fmt .Sprintf ("admission configuration %q is missing" , configName ), false , nil
427441 }
428442
429443 entries , _ , err := unstructured .NestedSlice (obj .Object , "webhooks" )
430444 if err != nil {
431- return false , aicrErrors .Wrap (aicrErrors .ErrCodeInternal ,
445+ return "" , false , aicrErrors .Wrap (aicrErrors .ErrCodeInternal ,
432446 fmt .Sprintf ("failed to read webhooks from %s %q" , gvr .Resource , configName ), err )
433447 }
434448 for _ , e := range entries {
@@ -437,10 +451,11 @@ func hasTrainerWebhook(ctx context.Context, dynamicClient dynamic.Interface,
437451 continue
438452 }
439453 if entry [keyName ] == webhookName {
440- return true , nil
454+ return "" , true , nil
441455 }
442456 }
443- return false , nil
457+ return fmt .Sprintf ("admission configuration %q exists but does not contain the %q webhook" ,
458+ configName , webhookName ), false , nil
444459}
445460
446461// trainerResourceClient returns the namespaced or cluster-scoped client for gvr.
@@ -556,7 +571,19 @@ func ensureTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface
556571// Both paths route through here so the verdict does not depend on whether the clock
557572// ran out during a probe or during a sleep: the same cluster state must not produce
558573// two different codes.
559- func classifyPollExpiry (ctx , pollCtx context.Context , last trainerInstall ) error {
574+ //
575+ // observed carries a concrete incomplete observation — a probe that succeeded and
576+ // named the object that is not there — and it is what separates the two failing
577+ // codes. NotFound may only be synthesized from such an observation. Deriving it from
578+ // an empty one would report a degraded or slow apiserver, where every probe attempt
579+ // was a read timeout and nothing was ever read, as a customer deployment defect; and
580+ // it would report a probe that found the installation complete as one that found
581+ // nothing. With no observation to stand on, the honest verdict is the timeout that
582+ // actually happened. The allowance is enforced either way — only the classification
583+ // differs.
584+ //
585+ // unobserved is the reason to report in that case.
586+ func classifyPollExpiry (ctx , pollCtx context.Context , observed trainerInstall , unobserved string ) error {
560587 if ctx .Err () != nil {
561588 return aicrErrors .Wrap (aicrErrors .ErrCodeTimeout ,
562589 "canceled while waiting for the recipe-declared Kubeflow Trainer" , ctx .Err ())
@@ -565,20 +592,25 @@ func classifyPollExpiry(ctx, pollCtx context.Context, last trainerInstall) error
565592 return nil
566593 }
567594
595+ verdict := fmt .Sprintf (
596+ "the recipe declares the %s component but its Kubeflow Trainer installation " +
597+ "did not become complete within %s: %%s. The benchmark will not self-install " +
598+ "over a delivered component that failed to deploy" ,
599+ kubeflowTrainerComponent , trainerInstallWaitTimeout )
600+
601+ if observed .Incomplete == "" {
602+ if unobserved == "" {
603+ unobserved = "the allowance expired before any probe could tell"
604+ }
605+ return aicrErrors .New (aicrErrors .ErrCodeTimeout , fmt .Sprintf (verdict , unobserved ))
606+ }
607+
568608 // ErrCodeNotFound, not Unavailable: the read succeeded and the answer was "not
569609 // deployed". Unavailable is this package's code for a transport failure — see
570610 // the decision table on validators.Require — and using it here would file a
571611 // product defect alongside apiserver hiccups, telling whoever triages it to
572612 // re-run rather than to fix their deployment.
573- reason := last .Incomplete
574- if reason == "" {
575- reason = "no complete installation was found"
576- }
577- return aicrErrors .New (aicrErrors .ErrCodeNotFound , fmt .Sprintf (
578- "the recipe declares the %s component but its Kubeflow Trainer installation " +
579- "did not become complete within %s: %s. The benchmark will not self-install " +
580- "over a delivered component that failed to deploy" ,
581- kubeflowTrainerComponent , trainerInstallWaitTimeout , reason ))
613+ return aicrErrors .New (aicrErrors .ErrCodeNotFound , fmt .Sprintf (verdict , observed .Incomplete ))
582614}
583615
584616// awaitTrainerController waits for a Trainer the benchmark does not own to finish
@@ -637,7 +669,12 @@ func waitForDeclaredTrainer(ctx context.Context, dynamicClient dynamic.Interface
637669 // exactly when the apiserver is degraded that the transport signal is
638670 // worth the most.
639671 if errors .Is (err , aicrErrors .New (aicrErrors .ErrCodeTimeout , "" )) {
640- if verdict := classifyPollExpiry (ctx , pollCtx , last ); verdict != nil {
672+ // last is the newest concrete observation, and it is the zero value
673+ // until some probe completes. When every attempt was cut short by the
674+ // deadline there is nothing on the cluster to point at, so this stays
675+ // a timeout rather than becoming a deployment defect.
676+ if verdict := classifyPollExpiry (ctx , pollCtx , last ,
677+ "no probe completed before the allowance expired" ); verdict != nil {
641678 return trainerInstall {}, verdict
642679 }
643680 }
@@ -660,13 +697,16 @@ func waitForDeclaredTrainer(ctx context.Context, dynamicClient dynamic.Interface
660697 // The complete case claims only what was observed. The wait never measures when
661698 // the installation became complete, only when it saw that it was, so wording it
662699 // as a transition would assert a time nobody read.
663- expired := install
700+ //
701+ // A complete probe is not an incomplete observation, so it is reported as the
702+ // timeout it is: the installation is there, and the only finding is a rollout
703+ // or a read slower than the budget.
704+ observed , unobserved := install , ""
664705 if ok {
665- expired = trainerInstall {
666- Incomplete : "the installation was observed complete only after the allowance expired" ,
667- }
706+ observed = trainerInstall {}
707+ unobserved = "the installation was observed complete only after the allowance expired"
668708 }
669- if verdict := classifyPollExpiry (ctx , pollCtx , expired ); verdict != nil {
709+ if verdict := classifyPollExpiry (ctx , pollCtx , observed , unobserved ); verdict != nil {
670710 return trainerInstall {}, verdict
671711 }
672712 if ok {
@@ -676,7 +716,8 @@ func waitForDeclaredTrainer(ctx context.Context, dynamicClient dynamic.Interface
676716
677717 select {
678718 case <- pollCtx .Done ():
679- if verdict := classifyPollExpiry (ctx , pollCtx , last ); verdict != nil {
719+ if verdict := classifyPollExpiry (ctx , pollCtx , last ,
720+ "no probe completed before the allowance expired" ); verdict != nil {
680721 return trainerInstall {}, verdict
681722 }
682723 // unreachable: this case fires only once pollCtx is done, and
0 commit comments