Skip to content

Commit bf6eba4

Browse files
committed
test(validator): make the fall-through guard actually reach the branch
The regression row added last round could not fail. It seeded the fake with completeTrainerInstall(), so the first probe in ensureTrainerInstalled succeeded, waitForDeclaredTrainer was never entered, and the row exercised the same path as the one above it. A guard that passes whether or not the bug is present is worse than no guard, because it reports coverage it does not have. Replaced with a stateful fake reproducing the state the fall-through actually lived in: incomplete on the first probe, complete on a later one, which is what a chart still landing looks like. The test asserts no resources were claimed for cleanup and that the probe ran more than once, so it fails if the wait is never entered. Verified by control: reintroducing the fall-through makes it fail, and restoring the fix makes it pass. Also carries discovery's own reason out of isTrainerInstalled instead of flattening it to a bare false. 'No admission configuration at all' and 'the configuration exists but names no Service' are different fixes, and both are reachable during the rollout this PR now waits through. Corrects the doc comment that still claimed the same recipe behaves identically regardless of live state. What the recipe determines is what a missing installation *means*; execution still differs across the undeclared rows, which is why that wording was withdrawn in #2297. Refs #2297 Signed-off-by: Yuan Chen <yuanchen97@gmail.com>
1 parent b578658 commit bf6eba4

2 files changed

Lines changed: 63 additions & 12 deletions

File tree

validators/performance/trainer_ensure_test.go

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import (
1818
"context"
1919
stderrors "errors"
2020
"strings"
21+
"sync/atomic"
2122
"testing"
2223
"time"
2324

2425
aicrErrors "github.com/NVIDIA/aicr/pkg/errors"
2526
apierrors "k8s.io/apimachinery/pkg/api/errors"
2627
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2728
"k8s.io/apimachinery/pkg/runtime"
29+
"k8s.io/apimachinery/pkg/runtime/schema"
2830
k8stesting "k8s.io/client-go/testing"
2931
)
3032

@@ -277,15 +279,6 @@ func TestEnsureTrainerInstalled_RecipeDrivenLifecycle(t *testing.T) {
277279
declared: false,
278280
objects: completeTrainerInstall(),
279281
},
280-
{
281-
// Regression guard for a fall-through the linter caught once already:
282-
// after the declared wait succeeds, the code must take the readiness
283-
// path and stop, not continue into the install path and reinstall over
284-
// a Trainer the recipe delivered.
285-
name: "declared and delivered: does not fall through to install",
286-
declared: true,
287-
objects: completeTrainerInstall(),
288-
},
289282
}
290283

291284
for _, tt := range tests {
@@ -362,3 +355,51 @@ func TestWaitForDeclaredTrainer_CanceledRunIsNotADeploymentDefect(t *testing.T)
362355
t.Errorf("error code = %v, want ErrCodeTimeout", err)
363356
}
364357
}
358+
359+
// TestEnsureTrainerInstalled_DeclaredRolloutDoesNotFallThrough pins the branch the
360+
// fall-through actually lived in.
361+
//
362+
// A table row seeded with completeTrainerInstall() cannot reach it: the first probe
363+
// in ensureTrainerInstalled succeeds, so waitForDeclaredTrainer is never entered and
364+
// the row exercises the same path as the one above it. The supported rollout state —
365+
// initially incomplete, complete on a later poll — is the one that enters the wait
366+
// and then must return rather than continuing into the install path.
367+
func TestEnsureTrainerInstalled_DeclaredRolloutDoesNotFallThrough(t *testing.T) {
368+
defer withShortTrainerWait(t)()
369+
370+
client := newTrainerFakeClient()
371+
var probes int32
372+
// Report incomplete on the first probe and complete afterwards, which is what a
373+
// chart still landing looks like. The reactor answers only the first read the
374+
// probe makes; a NotFound there is enough to make the whole probe incomplete.
375+
client.PrependReactor("get", "customresourcedefinitions",
376+
func(k8stesting.Action) (bool, runtime.Object, error) {
377+
if atomic.AddInt32(&probes, 1) == 1 {
378+
return true, nil, apierrors.NewNotFound(
379+
schema.GroupResource{Group: "apiextensions.k8s.io", Resource: "customresourcedefinitions"},
380+
"trainjobs.trainer.kubeflow.org")
381+
}
382+
return false, nil, nil // fall through to the tracker
383+
})
384+
for _, obj := range completeTrainerInstall() {
385+
if err := client.Tracker().Add(obj); err != nil {
386+
t.Fatalf("seeding fake: %v", err)
387+
}
388+
}
389+
390+
// A nil discovery client makes installTrainer panic or error immediately, so if
391+
// the code falls through to the install path this test fails loudly rather than
392+
// silently passing.
393+
refs, err := ensureTrainerInstalled(context.Background(), client, nil, true)
394+
if err != nil {
395+
t.Fatalf("unexpected error: %v", err)
396+
}
397+
if len(refs) != 0 {
398+
t.Errorf("refs = %d, want 0: a Trainer the recipe delivered must never be "+
399+
"claimed for cleanup, and a non-empty result means the install path ran", len(refs))
400+
}
401+
if atomic.LoadInt32(&probes) < 2 {
402+
t.Errorf("probes = %d, want >= 2: the wait was never entered, so this test "+
403+
"does not cover the branch it claims to", probes)
404+
}
405+
}

validators/performance/trainer_lifecycle.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,16 @@ func isTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface) (t
250250
// makes this work for both the self-install overlay and the Helm chart.
251251
install, found, err := discoverTrainerInstall(ctx, dynamicClient,
252252
trainerValidatingWebhookGVR, trainerValidatingWebhookConfig, trainerValidatingWebhookName)
253-
if err != nil || !found {
253+
if err != nil {
254254
return trainerInstall{}, false, err
255255
}
256+
if !found {
257+
// Carry discovery's own reason out rather than flattening it to a bare
258+
// false: it is the difference between "no admission configuration at all"
259+
// and "the configuration is there but names no Service", and the operator
260+
// needs to know which to fix.
261+
return install, false, nil
262+
}
256263

257264
ok, err := hasTrainerWebhook(ctx, dynamicClient,
258265
trainerMutatingWebhookGVR, trainerMutatingWebhookConfig, trainerMutatingWebhookName)
@@ -456,8 +463,11 @@ func trainerResourceClient(dynamicClient dynamic.Interface,
456463
// - not declared, and absent: install an ephemeral fixture and tear it down, as
457464
// before. There is nothing to mask, because nothing was promised.
458465
//
459-
// This is deliberately keyed on the recipe rather than on live cluster state, so the
460-
// same recipe behaves the same way regardless of what happens to be installed.
466+
// This is deliberately keyed on the recipe rather than on live cluster state: what a
467+
// missing installation *means* is now a property of the recipe, not of whatever
468+
// happens to be on the cluster. Execution still differs across the undeclared rows —
469+
// a pre-existing Trainer is reused, an absent one is installed — which is why the
470+
// earlier "behaves identically regardless of live state" wording was withdrawn.
461471
func ensureTrainerInstalled(ctx context.Context, dynamicClient dynamic.Interface,
462472
discoveryClient discovery.DiscoveryInterface, recipeDeclaresTrainer bool) ([]trainerResourceRef, error) {
463473

0 commit comments

Comments
 (0)