Skip to content

Commit b5bd713

Browse files
committed
test(e2e): port DCGM validation into the Go e2e harness
#442 replaced the bash E2E workflow with the Go/Ginkgo harness, dropping the job that ran the DCGM validators. Re-home that coverage natively: - add assertions/dcgm.go: scrape dcgm-exporter via the API-server pod proxy (no curl/port-forward) and assert DEV telemetry, GPM PROF metrics on Hopper+, time-varying power, and DCGM_FI_DEV_XID_ERRORS; - enable dcgmExporter in the operator values and add two gpu-operator specs (dcgm / xid), Xid last since it leaves the mock failed; - delete the interim validate-dcgm-{metrics,xid}.sh (keep spike-dcgm.sh). Signed-off-by: Aleksei Vasilevskii <avasilevskii@nvidia.com>
1 parent 66221dc commit b5bd713

10 files changed

Lines changed: 273 additions & 265 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
`DCGM_FI_PROF_*` profiling metrics on Hopper+ profiles — architecture-gated
1515
like real NVML, with `gpm.supported` / PCIe-rate config overrides; pre-Hopper
1616
reports GPM unsupported. E2E coverage runs real dcgm-exporter under the GPU
17-
Operator: `validate-dcgm-metrics.sh` asserts DEV + PROF and time-varying
18-
telemetry, `validate-dcgm-xid.sh` asserts `DCGM_FI_DEV_XID_ERRORS` under
19-
failure injection, and `spike-dcgm.sh` providesa container-level recipe. (#370)
17+
Operator via the Go harness (`gpu-operator` scenario, `dcgm`/`xid` labels):
18+
it asserts DEV + PROF and time-varying telemetry, plus `DCGM_FI_DEV_XID_ERRORS`
19+
under failure injection. `spike-dcgm.sh` provides a container-level recipe. (#370)
2020

2121
### Changed
2222
- ComputeDomain simulation now runs the REAL `nvidia-imex` daemon in NO

tests/e2e/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,13 @@ The scenario waits for the operator validator pod, records GFD labels, and
183183
verifies profile-derived allocatable `nvidia.com/gpu` resources from the bundled
184184
device plugin.
185185

186+
With `dcgmExporter` enabled in the operator values, the scenario also validates
187+
DCGM against the mock NVML ([`go/assertions/dcgm.go`](go/assertions/dcgm.go),
188+
`dcgm` / `xid` labels). It scrapes dcgm-exporter through the API-server pod proxy
189+
and asserts `DCGM_FI_DEV_*` telemetry, time-varying power, and `DCGM_FI_PROF_*`
190+
GPM metrics on Hopper+ profiles, then injects an Xid and asserts
191+
`DCGM_FI_DEV_XID_ERRORS` (last, since it leaves the mock in a failed state).
192+
186193
## Multi-Node Scenario
187194

188195
The `multi-node` scenario creates a heterogeneous Kind fleet from
@@ -256,6 +263,8 @@ Use-case labels:
256263
- `device-plugin`
257264
- `dra`
258265
- `gpu-operator`
266+
- `dcgm`
267+
- `xid`
259268
- `multi-node`
260269
- `nri`
261270
- `nri-inject`

tests/e2e/VERSION-MATRIX.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ dcgm-exporter runs with its embedded nv-hostengine against the mock NVML:
4141
driver-internal perfworks path there, which cannot be mocked.
4242
- **Failure injection** (`DCGM_FI_DEV_XID_ERRORS`): CI injects an Xid via the
4343
nvml-mock failure-injection knobs and asserts dcgm-exporter surfaces the code
44-
(`tests/e2e/validate-dcgm-xid.sh`). Health watches (`dcgmi health`) for
44+
(Go `gpu-operator` scenario, `xid` label). Health watches (`dcgmi health`) for
4545
PCIe/ECC/NVLink/thermal/power also work in the container-level spike.
46-
- Validated in CI by `tests/e2e/validate-dcgm-metrics.sh` and
47-
`tests/e2e/validate-dcgm-xid.sh`; the container-level recipe is
46+
- Validated in CI by the Go `gpu-operator` scenario (`dcgm`/`xid` labels,
47+
`tests/e2e/go/assertions/dcgm.go`); the container-level recipe is
4848
`tests/e2e/spike-dcgm.sh`.
4949

5050
### Not Supported

tests/e2e/go/assertions/dcgm.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
//go:build e2e
2+
3+
// Copyright 2026 NVIDIA CORPORATION
4+
// SPDX-License-Identifier: Apache-2.0
5+
6+
package assertions
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"slices"
12+
"strconv"
13+
"strings"
14+
"time"
15+
16+
ginkgo "github.com/onsi/ginkgo/v2"
17+
"github.com/onsi/gomega"
18+
19+
"github.com/NVIDIA/k8s-test-infra/tests/e2e/go/framework/kube"
20+
)
21+
22+
// dcgm-exporter field names asserted here.
23+
const (
24+
dcgmExporterSelector = "app=nvidia-dcgm-exporter"
25+
dcgmExporterDaemonSet = "nvidia-dcgm-exporter"
26+
dcgmExporterPort = 9400
27+
28+
fiDevGPUTemp = "DCGM_FI_DEV_GPU_TEMP"
29+
fiDevPowerUsage = "DCGM_FI_DEV_POWER_USAGE"
30+
fiDevXidErrors = "DCGM_FI_DEV_XID_ERRORS"
31+
fiProfPCIeTx = "DCGM_FI_PROF_PCIE_TX_BYTES"
32+
)
33+
34+
// DCGMDeviceMetrics asserts dcgm-exporter telemetry from the mock NVML:
35+
// - one DCGM_FI_DEV_GPU_TEMP sample per GPU, each in a plausible range;
36+
// - positive DCGM_FI_DEV_POWER_USAGE for every GPU;
37+
// - a modelName label matching the profile's display name;
38+
// - on GPM (Hopper+) profiles, one positive DCGM_FI_PROF_PCIE_TX_BYTES per GPU;
39+
// - power that varies over time.
40+
func DCGMDeviceMetrics(ctx context.Context, k *kube.Client, ns, gpuName string, gpuCount int, gpm bool, timeout, poll time.Duration) {
41+
ginkgo.GinkgoHelper()
42+
43+
WaitDaemonSetReady(ctx, k, ns, dcgmExporterDaemonSet, timeout, poll)
44+
pod := dcgmExporterPod(ctx, k, ns)
45+
46+
// Poll until the first DEV temperature samples appear.
47+
var metrics string
48+
ginkgo.By("scraping dcgm-exporter /metrics")
49+
gomega.Eventually(func() (int, error) {
50+
var err error
51+
metrics, err = scrapeDCGM(ctx, k, ns, pod)
52+
if err != nil {
53+
return 0, err
54+
}
55+
return len(promValues(metrics, fiDevGPUTemp)), nil
56+
}).WithContext(ctx).WithTimeout(timeout).WithPolling(poll).
57+
Should(gomega.BeNumerically(">", 0), "no %s samples from dcgm-exporter", fiDevGPUTemp)
58+
59+
temps := promValues(metrics, fiDevGPUTemp)
60+
gomega.Expect(temps).To(gomega.HaveLen(gpuCount), "%s sample count", fiDevGPUTemp)
61+
for _, t := range temps {
62+
gomega.Expect(t).To(gomega.And(gomega.BeNumerically(">=", 20), gomega.BeNumerically("<=", 100)),
63+
"%s value %.1f outside plausible range [20,100]", fiDevGPUTemp, t)
64+
}
65+
66+
powers := promValues(metrics, fiDevPowerUsage)
67+
gomega.Expect(powers).NotTo(gomega.BeEmpty(), "%s missing", fiDevPowerUsage)
68+
for _, p := range powers {
69+
gomega.Expect(p).To(gomega.BeNumerically(">", 0), "%s not positive", fiDevPowerUsage)
70+
}
71+
72+
gomega.Expect(metrics).To(gomega.ContainSubstring(fmt.Sprintf("modelName=%q", gpuName)),
73+
"modelName label %q not found in dcgm-exporter metrics", gpuName)
74+
75+
// GPM profiling metrics; present only on Hopper+ profiles.
76+
if gpm {
77+
ginkgo.By("checking GPM profiling metrics (DCGM_FI_PROF_*)")
78+
gomega.Eventually(func() (int, error) {
79+
var err error
80+
metrics, err = scrapeDCGM(ctx, k, ns, pod)
81+
if err != nil {
82+
return 0, err
83+
}
84+
return len(promValues(metrics, fiProfPCIeTx)), nil
85+
}).WithContext(ctx).WithTimeout(timeout).WithPolling(poll).
86+
Should(gomega.Equal(gpuCount), "%s sample count (GPM path)", fiProfPCIeTx)
87+
for _, v := range promValues(metrics, fiProfPCIeTx) {
88+
gomega.Expect(v).To(gomega.BeNumerically(">", 0), "%s not positive", fiProfPCIeTx)
89+
}
90+
}
91+
92+
// Power must differ across two scrapes (dynamic metrics).
93+
ginkgo.By("checking DCGM_FI_DEV_POWER_USAGE varies over time")
94+
baseline := promRaw(metrics, fiDevPowerUsage)
95+
gomega.Expect(baseline).NotTo(gomega.BeEmpty(), "%s not present for time-varying check", fiDevPowerUsage)
96+
gomega.Eventually(func() (bool, error) {
97+
cur, err := scrapeDCGM(ctx, k, ns, pod)
98+
if err != nil {
99+
return false, err
100+
}
101+
return !slices.Equal(promRaw(cur, fiDevPowerUsage), baseline), nil
102+
}).WithContext(ctx).WithTimeout(timeout).WithPolling(poll).
103+
Should(gomega.BeTrue(), "%s did not vary over time", fiDevPowerUsage)
104+
}
105+
106+
// DCGMXidReported polls until at least one GPU reports xid as
107+
// DCGM_FI_DEV_XID_ERRORS (healthy default is 0).
108+
func DCGMXidReported(ctx context.Context, k *kube.Client, ns string, xid int, timeout, poll time.Duration) {
109+
ginkgo.GinkgoHelper()
110+
111+
WaitDaemonSetReady(ctx, k, ns, dcgmExporterDaemonSet, timeout, poll)
112+
pod := dcgmExporterPod(ctx, k, ns)
113+
114+
ginkgo.By(fmt.Sprintf("waiting for %s == %d", fiDevXidErrors, xid))
115+
gomega.Eventually(func() (bool, error) {
116+
metrics, err := scrapeDCGM(ctx, k, ns, pod)
117+
if err != nil {
118+
return false, err
119+
}
120+
for _, v := range promValues(metrics, fiDevXidErrors) {
121+
if int(v) == xid {
122+
return true, nil
123+
}
124+
}
125+
return false, nil
126+
}).WithContext(ctx).WithTimeout(timeout).WithPolling(poll).
127+
Should(gomega.BeTrue(), "%s did not report injected Xid %d", fiDevXidErrors, xid)
128+
}
129+
130+
func dcgmExporterPod(ctx context.Context, k *kube.Client, ns string) string {
131+
ginkgo.GinkgoHelper()
132+
pod, err := k.FirstPodName(ctx, ns, dcgmExporterSelector)
133+
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "find dcgm-exporter pod")
134+
gomega.Expect(pod).NotTo(gomega.BeEmpty(), "dcgm-exporter pod not found in %s", ns)
135+
return pod
136+
}
137+
138+
// scrapeDCGM fetches the exporter's Prometheus text via the API-server pod proxy.
139+
func scrapeDCGM(ctx context.Context, k *kube.Client, ns, pod string) (string, error) {
140+
raw := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s:%d/proxy/metrics", ns, pod, dcgmExporterPort)
141+
return k.GetRawQuiet(ctx, raw)
142+
}
143+
144+
// promRaw returns the value token (last field) of every `metric{...}` series.
145+
func promRaw(metrics, metric string) []string {
146+
prefix := metric + "{"
147+
var out []string
148+
for _, line := range strings.Split(metrics, "\n") {
149+
line = strings.TrimSpace(line)
150+
if !strings.HasPrefix(line, prefix) {
151+
continue
152+
}
153+
if fields := strings.Fields(line); len(fields) >= 2 {
154+
out = append(out, fields[len(fields)-1])
155+
}
156+
}
157+
return out
158+
}
159+
160+
// promValues is promRaw parsed to float64 (unparseable tokens dropped).
161+
func promValues(metrics, metric string) []float64 {
162+
raw := promRaw(metrics, metric)
163+
out := make([]float64, 0, len(raw))
164+
for _, s := range raw {
165+
if v, err := strconv.ParseFloat(s, 64); err == nil {
166+
out = append(out, v)
167+
}
168+
}
169+
return out
170+
}

tests/e2e/go/assets/gpu-operator-values.yaml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,18 @@ driver:
2828
toolkit:
2929
enabled: false
3030

31-
# DCGM is not supported by the mock NVML (requires full driver stack).
31+
# nv-hostengine DaemonSet not needed: dcgm-exporter embeds the host engine.
3232
dcgm:
3333
enabled: false
3434

35+
# dcgm-exporter runs against the mock NVML (DEV field values + mock GPM for PROF).
3536
dcgmExporter:
36-
enabled: false
37+
enabled: true
38+
env:
39+
- name: NVIDIA_DRIVER_ROOT
40+
value: "/var/lib/nvml-mock/driver"
41+
- name: DCGM_EXPORTER_COLLECT_INTERVAL
42+
value: "5000"
3743

3844
# MIG is not supported by the mock NVML — disable manager and set strategy to none.
3945
# Without this, the device-plugin enumerates MIG devices and the CDI spec generator
@@ -43,7 +49,7 @@ mig:
4349
migManager:
4450
enabled: false
4551

46-
# Node status exporter depends on DCGM — disable.
52+
# Node status exporter is untested with the mock — keep disabled.
4753
nodeStatusExporter:
4854
enabled: false
4955

tests/e2e/go/framework/kube/kube.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,12 @@ func (c *Client) KubectlCombined(ctx context.Context, args ...string) (string, e
425425
res, err := c.kubectl(ctx, args...)
426426
return res.Combined(), err
427427
}
428+
429+
// GetRawQuiet fetches an API path via `kubectl get --raw` without streaming the
430+
// (potentially large) response body to the Ginkgo writer. The body is still
431+
// captured and returned.
432+
func (c *Client) GetRawQuiet(ctx context.Context, path string) (string, error) {
433+
full := append(c.base(), "get", "--raw", path)
434+
res, err := runner.RunQuiet(ctx, "kubectl", full...)
435+
return res.Combined(), err
436+
}

tests/e2e/go/scenario_gpu_operator_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package e2e
88
import (
99
"context"
1010
"os"
11+
"strconv"
1112
"time"
1213

1314
. "github.com/onsi/ginkgo/v2"
@@ -67,10 +68,65 @@ var _ = Describe("nvml-mock GPU Operator", Label("gpu-operator"), Ordered, func(
6768
}
6869
assertions.WaitAllocatableGPU(ctx, h.Kube, node, p.ExpectedGPUs(), config.ReadyTimeout(), config.PollInterval())
6970
})
71+
72+
It("exports DCGM device metrics that vary over time", Label("dcgm"), func(ctx SpecContext) {
73+
assertions.DCGMDeviceMetrics(ctx, h.Kube, gpuOperatorNamespace,
74+
p.DisplayName, p.ExpectedGPUs(), gpmProfiles[name],
75+
config.ReadyTimeout(), config.PollInterval())
76+
})
77+
78+
It("surfaces an injected Xid through dcgm-exporter", Label("dcgm", "xid"), func(ctx SpecContext) {
79+
// Runs last: leaves the mock in a failed state.
80+
injectXidAndValidate(ctx, h, xidTestCode)
81+
})
7082
})
7183
}
7284
})
7385

86+
// gpmProfiles are the Hopper+ profiles that serve DCGM_FI_PROF_* GPM metrics.
87+
var gpmProfiles = map[string]bool{"h100": true, "b200": true, "gb200": true, "gb300": true}
88+
89+
// xidTestCode is the Xid injected and asserted on (79 = GPU fallen off the bus).
90+
const xidTestCode = 79
91+
92+
// injectXidAndValidate enables failure injection, rolls nvml-mock and
93+
// dcgm-exporter to reload the mock config, then asserts DCGM_FI_DEV_XID_ERRORS.
94+
// ecc_uncorrectable keeps the device scrapable while the Xid event fires.
95+
func injectXidAndValidate(ctx context.Context, h *harness.Harness, xid int) {
96+
GinkgoHelper()
97+
By("enabling failure injection (ecc_uncorrectable, xid) on nvml-mock")
98+
Expect(h.Helm.UpgradeInstall(ctx, helm.Release{
99+
Name: "nvml-mock",
100+
Chart: chartDir(),
101+
Namespace: nvmlMockNamespace,
102+
ReuseValues: true,
103+
Set: map[string]string{
104+
"gpu.failureInjection.enabled": "true",
105+
"gpu.failureInjection.mode": "ecc_uncorrectable",
106+
"gpu.failureInjection.after_calls": "1",
107+
"gpu.failureInjection.seed": "1",
108+
"gpu.failureInjection.xid.code": strconv.Itoa(xid),
109+
},
110+
Wait: true,
111+
Timeout: config.HelmTimeout(),
112+
})).To(Succeed(), "enable failure injection on nvml-mock")
113+
114+
rolloutRestart(ctx, h, nvmlMockNamespace, "nvml-mock")
115+
rolloutRestart(ctx, h, gpuOperatorNamespace, "nvidia-dcgm-exporter")
116+
117+
assertions.DCGMXidReported(ctx, h.Kube, gpuOperatorNamespace, xid,
118+
config.ReadyTimeout(), config.PollInterval())
119+
}
120+
121+
// rolloutRestart restarts a DaemonSet and blocks until the rollout completes.
122+
func rolloutRestart(ctx context.Context, h *harness.Harness, ns, ds string) {
123+
GinkgoHelper()
124+
_, err := h.Kube.KubectlCombined(ctx, "rollout", "restart", "daemonset/"+ds, "-n", ns)
125+
Expect(err).NotTo(HaveOccurred(), "rollout restart %s/%s", ns, ds)
126+
_, err = h.Kube.KubectlCombined(ctx, "rollout", "status", "daemonset/"+ds, "-n", ns, "--timeout=120s")
127+
Expect(err).NotTo(HaveOccurred(), "rollout status %s/%s", ns, ds)
128+
}
129+
74130
func installNVIDIAContainerToolkit(ctx context.Context, h *harness.Harness, node cluster.Node) {
75131
GinkgoHelper()
76132
Expect(dockerExec(ctx, node.Name, "bash", "-c", `

tests/e2e/go/suite_helm.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,20 @@ func installDemoChart(ctx context.Context, h *harness.Harness, prof string, coun
4040
func demoRelease(prof string, count int) helm.Release {
4141
repo, tag := splitImage(config.Image())
4242
set := map[string]string{
43-
"gpu.count": strconv.Itoa(count),
44-
"gpu.dynamicMetrics.enabled": "true",
45-
"gpu.profile": prof,
46-
"image.repository": repo,
47-
"image.tag": tag,
48-
"integrations.fakeGpuOperator.enabled": "true",
43+
"gpu.count": strconv.Itoa(count),
44+
"gpu.dynamicMetrics.enabled": "true",
45+
"gpu.profile": prof,
46+
"image.repository": repo,
47+
"image.tag": tag,
48+
"integrations.fakeGpuOperator.enabled": "true",
49+
// Steady, positive utilization so DCGM GPM PCIe throughput stays
50+
// nonzero on Hopper+; seed=1 for reproducibility.
51+
"gpu.dynamicMetrics.seed": "1",
52+
"gpu.dynamicMetrics.utilization.pattern": "steady",
53+
"gpu.dynamicMetrics.utilization.gpu_min": "50",
54+
"gpu.dynamicMetrics.utilization.gpu_max": "50",
55+
"gpu.dynamicMetrics.utilization.memory_min": "25",
56+
"gpu.dynamicMetrics.utilization.memory_max": "25",
4957
"terminationGracePeriodSeconds": "1",
5058
"updateStrategy.rollingUpdate.maxUnavailable": "100%",
5159
}

0 commit comments

Comments
 (0)