Skip to content

Commit 7fe2c7d

Browse files
committed
fix(gke): address review — retrofit flow, gate nil-safety, ctx, tests
- Rewrite the retrofit procedure for the bundle flow: step 0 deletes any hand-applied nvidia-driver-installer DaemonSet (Helm will not adopt the identically named object), and the ordering becomes driver-mode -> opt-out label -> bundle, with the scheduling gap and rollback semantics restated for the bundle-carried installer. - Make the installer's render gate nil-safe: a wholesale override that drops the installer key now renders nothing instead of failing the template (index of untyped nil). - Thread the deployment-phase context through gatedHealthCheckSuppressed and emptyRenderHealthCheckSuppressed (ctx-aware provider variants plus a per-manifest cancellation check), preserving the validator cancellation contract. - Add table-driven tests for the gated health-check dispatch: gated off, gated on, missing gate key, no manifests, unreadable manifest, non-gated component, canceled context. Signed-off-by: Atif Mahmood <atif1996@users.noreply.github.com>
1 parent 4565274 commit 7fe2c7d

4 files changed

Lines changed: 176 additions & 59 deletions

File tree

docs/integrator/gke-gpu-setup.md

Lines changed: 45 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -240,24 +240,24 @@ section above apply here unchanged.
240240
#### Retrofitting an existing pool
241241

242242
For a GPU node pool that already exists, do the retrofit in this order:
243-
standalone driver ready **first**, then the opt-out label, then the GPU
244-
Operator. The label takes effect the moment it lands, disabling the
245-
kube-system DaemonSet whose init container finalizes GKE's managed driver
246-
install — so on a labeled pool still set to `gpu-driver-version=default` (or
247-
`latest`), every node created in the interim (autoscaling, upgrade,
248-
auto-repair) comes up **driverless**. Deploying the standalone installer
249-
alone does not close that gap either — Google's `nvidia-driver-installer`
250-
DaemonSet
251-
[ignores nodes configured for automatic driver installation](https://cloud.google.com/kubernetes-engine/docs/troubleshooting/gpus#gpu_device_plugins_fail_with_crashloopbackoff_errors),
252-
so it skips every node of a pool whose driver mode is still `default`. Only
253-
the sequence below keeps the pool functional at every step.
254-
255-
**Step 1 — apply the standalone
256-
[`nvidia-driver-installer` DaemonSet](https://cloud.google.com/kubernetes-engine/docs/how-to/gpus#installing_drivers).**
257-
Applying it early is safe: it skips automatic-install nodes, so it is a
258-
no-op until step 2 flips the pool's driver mode.
259-
260-
**Step 2 — switch the pool to `gpu-driver-version=disabled`** (restate the
243+
driver mode first, then the opt-out label, then the bundle. The bundle
244+
carries the driver installer, so — unlike the hand-applied arrangement this
245+
replaces — there is nothing to apply out-of-band, but the installer only
246+
arrives with the bundle in step 3: between step 1 and step 3 the pool has a
247+
scheduling gap, described per step below. Plan the retrofit as one sitting
248+
with the bundle generated in advance.
249+
250+
**Step 0 — if migrating from a hand-applied installer, delete it first.**
251+
The bundle's DaemonSet shares the name `nvidia-driver-installer` in
252+
`kube-system`, and Helm will not adopt a pre-existing object — step 3 would
253+
fail. Nodes keep their loaded drivers; only the provisioning workload is
254+
replaced.
255+
256+
```bash
257+
kubectl delete daemonset -n kube-system nvidia-driver-installer --ignore-not-found
258+
```
259+
260+
**Step 1 — switch the pool to `gpu-driver-version=disabled`** (restate the
261261
pool's actual accelerator type and count):
262262

263263
```bash
@@ -267,31 +267,20 @@ gcloud container node-pools update POOL_NAME \
267267
--accelerator type=nvidia-h100-80gb,count=8,gpu-driver-version=disabled
268268
```
269269

270-
The driver-mode update may re-create the pool's nodes; with the standalone
271-
installer already applied, re-created and future nodes come up with a
272-
driver, and GKE's device plugin (not yet disabled) keeps advertising
273-
`nvidia.com/gpu` — the pool stays schedulable throughout.
274-
275-
**Step 3 — verify the driver before touching the label.** Every GPU node
276-
should be running the installer's pods and still report non-zero allocatable
277-
`nvidia.com/gpu` (advertised, for now, by GKE's plugin):
278-
279-
```bash
280-
kubectl get pods -n kube-system -l k8s-app=nvidia-driver-installer -o wide
281-
kubectl get nodes -l cloud.google.com/gke-accelerator \
282-
-o custom-columns='NAME:.metadata.name,GPU:.status.allocatable.nvidia\.com/gpu'
283-
```
284-
285-
**Step 4 — apply the opt-out label.** This begins the handoff: the label
286-
immediately evicts GKE's managed plugin, so from this point until step 5's
287-
Operator plugin registers, the pool has **no** `nvidia.com/gpu` advertiser
288-
and GPU pods will not schedule. That brief advertiser-free window is the
289-
accepted cost of the handoff direction — do **not** invert it by deploying
290-
the Operator's plugin onto a still-unlabeled pool, which would put two
291-
advertisers on the same nodes (the dual-advertisement state the
270+
The driver-mode update may re-create the pool's nodes. Until step 3's
271+
installer runs, re-created nodes come up **driverless while GKE's plugin
272+
still advertises** `nvidia.com/gpu` on them — GPU pods scheduled there will
273+
fail. Avoid scheduling GPU work from here until the handoff completes
274+
(cordon the pool's nodes for GPU workloads if the cluster is busy).
275+
276+
**Step 2 — apply the opt-out label.** This evicts GKE's managed plugin, so
277+
from this point until step 3's Operator plugin registers, the pool has
278+
**no** `nvidia.com/gpu` advertiser — which also stops the driverless nodes
279+
from being advertised. Do **not** invert the order by deploying the bundle
280+
onto a still-unlabeled pool: that would put two advertisers on the same
281+
nodes (the dual-advertisement state the
292282
[allocation-policy gates](#the-three-bundle-installer-settings) exist to
293-
prevent). Have the bundle from step 5 generated in advance to keep the
294-
window short, and avoid scheduling GPU work during it.
283+
prevent).
295284
Note that `--node-labels` on update **replaces** the pool's full user-label
296285
set: first list the labels the pool already carries, then pass the complete
297286
set with the new label appended:
@@ -315,18 +304,22 @@ expects — without it, `value(config.labels)` joins entries with semicolons,
315304
which the update rejects. Omitting an existing label removes it from the
316305
pool's nodes, which can break scheduling that depends on it.
317306

318-
**Step 5 — deploy the GPU Operator and wait for its plugin.** Deploy the
319-
AICR bundle generated with `--profile gpuStack=bundle-installer`, then wait
320-
until the Operator's device-plugin pods are Running on the labeled nodes and
321-
every GPU node again reports non-zero allocatable `nvidia.com/gpu` — that
322-
closes the advertiser-free window opened in step 4. Confirm the full result
323-
with the checks in [Verifying the handoff](#verifying-the-handoff).
307+
**Step 3 — deploy the bundle.** Deploy the AICR bundle generated with
308+
`--profile gpuStack=bundle-installer`. Its `gcp-driver-installer` DaemonSet
309+
installs the pinned driver on the labeled, driver-mode-disabled nodes
310+
(nodes that already have a loaded driver are skipped), and the GPU
311+
Operator's device plugin registers once the driver is ready — closing the
312+
window opened in steps 1–2. Wait until every GPU node again reports
313+
non-zero allocatable `nvidia.com/gpu`, then confirm the full result with
314+
the checks in [Verifying the handoff](#verifying-the-handoff).
324315

325316
**Rollback:** if the Operator's device plugin fails to come up after the
326317
label lands, remove the label (another `--node-labels` update passing the
327-
full set with the opt-out label omitted) — GKE's plugin returns and the pool
328-
resumes advertising GPUs, with the driver still supplied by the standalone
329-
installer.
318+
full set with the opt-out label omitted) — GKE's plugin returns and the
319+
pool resumes advertising GPUs. Nodes the bundle's installer already
320+
provisioned keep their driver; nodes re-created before step 3 ran are
321+
driverless until the pool's driver mode is restored
322+
(`gpu-driver-version=default`) or the bundle deploys.
330323

331324
#### Verifying the handoff
332325

@@ -426,7 +419,7 @@ stay `Pending`, even though the driver is present and healthy.
426419
(yet) registered. The label immediately evicts GKE's managed plugin, so
427420
until the Operator's plugin comes up, the node has no `nvidia.com/gpu`
428421
advertiser at all. A brief window in this state is the expected
429-
intermediate step of the retrofit handoff (step 4 of
422+
intermediate step of the retrofit handoff (step 2 of
430423
[Retrofitting an existing pool](#retrofitting-an-existing-pool)); it is a
431424
problem only when nothing closes it.
432425

recipes/components/gcp-driver-installer/manifests/nvidia-driver-installer.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@
5252
# bundle-installer profile value flips installer.enabled true.
5353
# Default-off fails closed: rendering the installer is a profile
5454
# decision, never a chart default.
55-
{{- if eq (toString (index (index $vals "installer") "enabled")) "true" }}
55+
{{- $installer := index $vals "installer" }}
56+
{{- $gate := "" }}
57+
{{- if $installer }}{{- $gate = toString (index $installer "enabled") }}{{- end }}
58+
{{- if eq $gate "true" }}
5659
---
5760
apiVersion: apps/v1
5861
kind: DaemonSet

validators/deployment/expected_resources.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ func checkExpectedResources(ctx *validators.Context) error {
246246
// in that case, mirroring the render-aware Go readiness check. Only
247247
// nodewright-customizations is subject to this; a render/read error
248248
// propagates rather than silently skipping. See #1844.
249-
suppressed, reason, suppressErr := gatedHealthCheckSuppressed(ref)
249+
suppressed, reason, suppressErr := gatedHealthCheckSuppressed(ctx.Ctx, ref)
250250
if suppressErr != nil {
251251
return suppressErr
252252
}
@@ -686,13 +686,14 @@ func isRuntimeRequiredTaint(t *corev1.Taint) bool {
686686
// suppress. Every other component's assert queues unconditionally.
687687
// Fail-closed throughout: a render or read error propagates so a broken
688688
// template is never mistaken for "nothing to assert".
689-
func gatedHealthCheckSuppressed(ref recipe.ComponentRef) (bool, string, error) {
689+
func gatedHealthCheckSuppressed(goCtx context.Context, ref recipe.ComponentRef) (bool, string, error) {
690690
switch ref.Name {
691691
case nodewrightCustomizationsComponent:
692+
//nolint:contextcheck // pre-existing ctx-less chain (expectedNodewrightNames); threading ctx through it is tracked separately from this dispatch.
692693
suppressed, err := nodewrightHealthCheckSuppressed(ref)
693694
return suppressed, "effective values suppress the tuning Skyhook CR (see #1844)", err
694695
case gcpDriverInstallerComponent:
695-
suppressed, err := emptyRenderHealthCheckSuppressed(ref)
696+
suppressed, err := emptyRenderHealthCheckSuppressed(goCtx, ref)
696697
return suppressed, "effective values gate the component off (installer.enabled=false); it renders no objects", err
697698
default:
698699
return false, "", nil
@@ -705,13 +706,13 @@ func gatedHealthCheckSuppressed(ref recipe.ComponentRef) (bool, string, error) {
705706
// profile values; a non-selected value renders an empty release). A static
706707
// health-check assert cannot see value gates and would fail on a healthy
707708
// cluster where the render is deliberately empty.
708-
func emptyRenderHealthCheckSuppressed(ref recipe.ComponentRef) (bool, error) {
709+
func emptyRenderHealthCheckSuppressed(goCtx context.Context, ref recipe.ComponentRef) (bool, error) {
709710
if len(ref.ManifestFiles) == 0 {
710711
// Nothing to render — leave the assert in place so its own failure
711712
// surfaces the problem.
712713
return false, nil
713714
}
714-
values, err := recipe.GetComponentValues(&ref)
715+
values, err := recipe.GetComponentValuesWithContext(goCtx, nil, &ref)
715716
if err != nil {
716717
return false, errors.Wrap(errors.ErrCodeInternal,
717718
fmt.Sprintf("failed to resolve effective values for component %s", ref.Name), err)
@@ -728,7 +729,15 @@ func emptyRenderHealthCheckSuppressed(ref recipe.ComponentRef) (bool, error) {
728729
Values: values,
729730
}
730731
for _, path := range ref.ManifestFiles {
731-
content, err := recipe.GetManifestContent(path)
732+
// Preserve the validator cancellation contract: reads and renders in
733+
// this loop must stop once the deployment phase is canceled.
734+
select {
735+
case <-goCtx.Done():
736+
return false, errors.Wrap(errors.ErrCodeTimeout,
737+
"deployment validation canceled during gated health-check evaluation", goCtx.Err())
738+
default:
739+
}
740+
content, err := recipe.GetManifestContentWithContext(goCtx, nil, path)
732741
if err != nil {
733742
return false, errors.Wrap(errors.ErrCodeInternal,
734743
fmt.Sprintf("failed to load manifest %s for component %s", path, ref.Name), err)

validators/deployment/expected_resources_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,3 +1526,115 @@ func TestRDMAFabricProbeCoverage_CountsCordonedOnFailClosed(t *testing.T) {
15261526
t.Errorf("total() = %d, want 2 (cordoned counted even on failure)", got)
15271527
}
15281528
}
1529+
1530+
// TestGatedHealthCheckSuppressed pins the render-aware static-assert
1531+
// suppression dispatch for values-gated components: the gcp-driver-installer
1532+
// health check is skipped exactly when the effective values gate the render
1533+
// off, other components' asserts queue unconditionally, and render/read
1534+
// failures propagate rather than being read as "nothing to assert".
1535+
func TestGatedHealthCheckSuppressed(t *testing.T) {
1536+
t.Parallel()
1537+
1538+
installerManifest := "components/gcp-driver-installer/manifests/nvidia-driver-installer.yaml"
1539+
tests := []struct {
1540+
name string
1541+
ref recipe.ComponentRef
1542+
wantSuppressed bool
1543+
wantErr bool
1544+
}{
1545+
{
1546+
name: "installer gated off (default values) suppresses the assert",
1547+
ref: recipe.ComponentRef{
1548+
Name: "gcp-driver-installer",
1549+
Type: recipe.ComponentTypeHelm,
1550+
ValuesFile: "components/gcp-driver-installer/values.yaml",
1551+
ManifestFiles: []string{installerManifest},
1552+
},
1553+
wantSuppressed: true,
1554+
},
1555+
{
1556+
// A wholesale override can drop the gate key entirely; the
1557+
// template must fail closed to not-rendering, never panic.
1558+
name: "missing gate key renders nothing and suppresses",
1559+
ref: recipe.ComponentRef{
1560+
Name: "gcp-driver-installer",
1561+
Type: recipe.ComponentTypeHelm,
1562+
ManifestFiles: []string{installerManifest},
1563+
},
1564+
wantSuppressed: true,
1565+
},
1566+
{
1567+
name: "installer gated on renders objects and keeps the assert",
1568+
ref: recipe.ComponentRef{
1569+
Name: "gcp-driver-installer",
1570+
Type: recipe.ComponentTypeHelm,
1571+
ManifestFiles: []string{installerManifest},
1572+
Overrides: map[string]any{
1573+
"installer": map[string]any{"enabled": true},
1574+
},
1575+
},
1576+
wantSuppressed: false,
1577+
},
1578+
{
1579+
name: "no manifests leaves the assert in place",
1580+
ref: recipe.ComponentRef{
1581+
Name: "gcp-driver-installer",
1582+
Type: recipe.ComponentTypeHelm,
1583+
},
1584+
wantSuppressed: false,
1585+
},
1586+
{
1587+
name: "unreadable manifest fails closed",
1588+
ref: recipe.ComponentRef{
1589+
Name: "gcp-driver-installer",
1590+
Type: recipe.ComponentTypeHelm,
1591+
ManifestFiles: []string{"components/gcp-driver-installer/manifests/no-such-file.yaml"},
1592+
},
1593+
wantErr: true,
1594+
},
1595+
{
1596+
name: "non-gated component queues unconditionally",
1597+
ref: recipe.ComponentRef{
1598+
Name: "gpu-operator",
1599+
Type: recipe.ComponentTypeHelm,
1600+
ManifestFiles: []string{installerManifest},
1601+
},
1602+
wantSuppressed: false,
1603+
},
1604+
}
1605+
for _, tt := range tests {
1606+
t.Run(tt.name, func(t *testing.T) {
1607+
t.Parallel()
1608+
suppressed, reason, err := gatedHealthCheckSuppressed(t.Context(), tt.ref)
1609+
if tt.wantErr {
1610+
if err == nil {
1611+
t.Fatalf("gatedHealthCheckSuppressed() error = nil, want failure")
1612+
}
1613+
return
1614+
}
1615+
if err != nil {
1616+
t.Fatalf("gatedHealthCheckSuppressed() error = %v", err)
1617+
}
1618+
if suppressed != tt.wantSuppressed {
1619+
t.Errorf("suppressed = %v, want %v", suppressed, tt.wantSuppressed)
1620+
}
1621+
if suppressed && reason == "" {
1622+
t.Error("suppressed with an empty reason — operators need the why")
1623+
}
1624+
})
1625+
}
1626+
1627+
t.Run("canceled context stops manifest evaluation", func(t *testing.T) {
1628+
t.Parallel()
1629+
ctx, cancel := context.WithCancel(context.Background())
1630+
cancel()
1631+
_, _, err := gatedHealthCheckSuppressed(ctx, recipe.ComponentRef{
1632+
Name: "gcp-driver-installer",
1633+
Type: recipe.ComponentTypeHelm,
1634+
ManifestFiles: []string{installerManifest},
1635+
})
1636+
if err == nil {
1637+
t.Fatal("gatedHealthCheckSuppressed() error = nil, want cancellation")
1638+
}
1639+
})
1640+
}

0 commit comments

Comments
 (0)