Skip to content

Commit 3eb7fcf

Browse files
committed
fix(validator): synthesize NotFound only from a concrete incomplete observation
classifyPollExpiry returned NotFound for any poll-context expiry, including the case where every probe attempt was a read timeout and no probe ever observed an incomplete installation. A degraded or slow apiserver was therefore filed as a customer deployment defect. NotFound now requires a successful probe that named a missing object; otherwise the verdict keeps the timeout classification. The rollout allowance is still enforced either way. hasTrainerWebhook also flattened an absent mutating admission configuration and one that exists but serves another operator's webhook to the same bare false, so the caller told an operator to create an object already on the cluster. It now returns the specific reason, matching how discoverTrainerInstall carries its own reason out. Signed-off-by: Yuan Chen <yuanchen97@gmail.com>
1 parent c499e89 commit 3eb7fcf

3 files changed

Lines changed: 193 additions & 34 deletions

File tree

validators/performance/trainer_ensure_test.go

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -464,9 +464,17 @@ func TestWaitForDeclaredTrainer_SlowProbeCannotOutrunTheDeadline(t *testing.T) {
464464
t.Fatal("wait returned success after its own deadline had passed; the rollout " +
465465
"allowance must bound the probe, not only the sleeps between probes")
466466
}
467-
if !stderrors.Is(err, aicrErrors.New(aicrErrors.ErrCodeNotFound, "")) {
468-
t.Errorf("error code = %v, want ErrCodeNotFound: the deadline expired locally "+
469-
"with the parent still live, which is a deployment that never completed", err)
467+
// Timeout, not NotFound. The allowance is still enforced — the wait fails — but
468+
// nothing here was ever read as missing: the installation underneath is complete
469+
// and the only thing that went wrong is a read slower than the budget. Filing
470+
// that as NotFound would send an operator to fix a deployment that is fine.
471+
if stderrors.Is(err, aicrErrors.New(aicrErrors.ErrCodeNotFound, "")) {
472+
t.Errorf("slow read reported as NotFound (%v); no probe observed anything "+
473+
"incomplete, so there is no deployment defect to file", err)
474+
}
475+
var se *aicrErrors.StructuredError
476+
if !stderrors.As(err, &se) || se.Code != aicrErrors.ErrCodeTimeout {
477+
t.Errorf("reported code = %v, want Timeout", err)
470478
}
471479
}
472480

@@ -551,9 +559,15 @@ func TestWaitForDeclaredTrainer_LateSuccessDoesNotOutrunTheDeadline(t *testing.T
551559
t.Fatal("wait returned success after its allowance had passed; expiry must be " +
552560
"rechecked before accepting a probe's result, not only before issuing it")
553561
}
554-
if !stderrors.Is(err, aicrErrors.New(aicrErrors.ErrCodeNotFound, "")) {
555-
t.Errorf("error code = %v, want ErrCodeNotFound: the deadline expired locally "+
556-
"with the parent still live", err)
562+
// The probe returned complete, so no incomplete installation was ever observed:
563+
// the verdict is the timeout that happened, not a deployment defect.
564+
if stderrors.Is(err, aicrErrors.New(aicrErrors.ErrCodeNotFound, "")) {
565+
t.Errorf("late success reported as NotFound (%v); the probe found the "+
566+
"installation complete, so nothing was missing", err)
567+
}
568+
var se *aicrErrors.StructuredError
569+
if !stderrors.As(err, &se) || se.Code != aicrErrors.ErrCodeTimeout {
570+
t.Errorf("reported code = %v, want Timeout", err)
557571
}
558572
// The reason must describe this probe, not the previous one. The installation is
559573
// complete here, so blaming a missing object would point the operator at something
@@ -650,3 +664,49 @@ func TestWaitForDeclaredTrainer_LateSuccessClaimsOnlyWhatWasObserved(t *testing.
650664
t.Errorf("reason does not state what was actually observed: %v", err)
651665
}
652666
}
667+
668+
// TestWaitForDeclaredTrainer_ReadTimeoutsAloneAreNotADeploymentDefect pins the case
669+
// where the allowance runs out without any probe ever reaching a conclusion.
670+
//
671+
// Every read the probe issues is cut short by the poll deadline, so no probe ever
672+
// observes an incomplete installation — there is nothing on the cluster the wait can
673+
// point at. Synthesizing NotFound from that empty observation would file a degraded
674+
// or slow apiserver as a customer deployment defect, which is the same
675+
// misclassification the NotFound/Unavailable split exists to prevent. With no
676+
// observation to stand on, the honest verdict is the timeout that actually happened.
677+
func TestWaitForDeclaredTrainer_ReadTimeoutsAloneAreNotADeploymentDefect(t *testing.T) {
678+
oldTimeout, oldInterval := trainerInstallWaitTimeout, trainerInstallPollInterval
679+
trainerInstallWaitTimeout = 20 * time.Millisecond
680+
trainerInstallPollInterval = time.Millisecond
681+
defer func() {
682+
trainerInstallWaitTimeout, trainerInstallPollInterval = oldTimeout, oldInterval
683+
}()
684+
685+
// Seed only the first CRD the probe reads, and stall that read past the
686+
// allowance. The read itself succeeds, so the probe learns nothing incomplete;
687+
// the deadline is already gone by the time the second read is issued, and
688+
// getTrainerObject's pre-read check turns it into a Timeout. That is the only
689+
// thing the wait ever hears back.
690+
client := newTrainerFakeClient(establishedCRD(trainerCRDTrainJobs))
691+
client.PrependReactor("get", "customresourcedefinitions",
692+
func(k8stesting.Action) (bool, runtime.Object, error) {
693+
time.Sleep(60 * time.Millisecond) // outlives the allowance
694+
return false, nil, nil // then let the tracker answer
695+
})
696+
697+
_, err := waitForDeclaredTrainer(context.Background(), client)
698+
if err == nil {
699+
t.Fatal("wait returned success after its allowance had passed")
700+
}
701+
if stderrors.Is(err, aicrErrors.New(aicrErrors.ErrCodeNotFound, "")) {
702+
t.Errorf("read timeouts reported as NotFound (%v); no probe ever observed an "+
703+
"incomplete installation, so there is no deployment defect to file", err)
704+
}
705+
var se *aicrErrors.StructuredError
706+
if !stderrors.As(err, &se) || se.Code != aicrErrors.ErrCodeTimeout {
707+
t.Errorf("reported code = %v, want Timeout", err)
708+
}
709+
if strings.Contains(err.Error(), "no complete installation was found") {
710+
t.Errorf("verdict claims nothing was found, but nothing was ever read: %v", err)
711+
}
712+
}

validators/performance/trainer_lifecycle.go

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
421432
func 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

validators/performance/trainer_probe_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ package main
1717
import (
1818
"context"
1919
stderrors "errors"
20+
"fmt"
2021
"net"
22+
"strings"
2123
"testing"
2224
"time"
2325

@@ -721,3 +723,59 @@ func TestTrainerResourceRefString(t *testing.T) {
721723
})
722724
}
723725
}
726+
727+
// TestIsTrainerInstalled_MutatingConfigReasonsAreDistinct pins the two shapes of a
728+
// missing mutating webhook apart.
729+
//
730+
// "The configuration does not exist" and "the configuration exists but does not
731+
// serve our webhook" need different remedies, and flattening both to the first tells
732+
// an operator to create an object that is already on the cluster — which, on the
733+
// generic upstream configuration names another operator may own, is the shape most
734+
// likely to be hit. The validating-webhook discovery path already keeps them apart;
735+
// this is the same contract on the mutating one.
736+
func TestIsTrainerInstalled_MutatingConfigReasonsAreDistinct(t *testing.T) {
737+
tests := []struct {
738+
name string
739+
objs []runtime.Object
740+
wantContains string
741+
wantAbsent string
742+
}{
743+
{
744+
name: "configuration absent",
745+
objs: withoutObject(completeTrainerInstall(), dropByName(trainerMutatingWebhookConfig)),
746+
wantContains: fmt.Sprintf("admission configuration %q is missing",
747+
trainerMutatingWebhookConfig),
748+
},
749+
{
750+
name: "configuration present but serving another operator's webhook",
751+
objs: append(
752+
withoutObject(completeTrainerInstall(), dropByName(trainerMutatingWebhookConfig)),
753+
webhookConfig("MutatingWebhookConfiguration", trainerMutatingWebhookConfig,
754+
"defaulter.other.example.com"),
755+
),
756+
wantContains: fmt.Sprintf("exists but does not contain the %q webhook",
757+
trainerMutatingWebhookName),
758+
wantAbsent: fmt.Sprintf("admission configuration %q is missing",
759+
trainerMutatingWebhookConfig),
760+
},
761+
}
762+
763+
for _, tt := range tests {
764+
t.Run(tt.name, func(t *testing.T) {
765+
install, installed, err := isTrainerInstalled(context.Background(), newTrainerFakeClient(tt.objs...))
766+
if err != nil {
767+
t.Fatalf("unexpected error: %v", err)
768+
}
769+
if installed {
770+
t.Fatal("incomplete Trainer installation reported as installed")
771+
}
772+
if !strings.Contains(install.Incomplete, tt.wantContains) {
773+
t.Errorf("reason = %q, want it to contain %q", install.Incomplete, tt.wantContains)
774+
}
775+
if tt.wantAbsent != "" && strings.Contains(install.Incomplete, tt.wantAbsent) {
776+
t.Errorf("reason %q tells the operator to create a configuration that "+
777+
"already exists", install.Incomplete)
778+
}
779+
})
780+
}
781+
}

0 commit comments

Comments
 (0)