Skip to content

Commit 1e0ea13

Browse files
authored
fix(evidence): gate verdicts on cleanup and stop over-claiming (NVIDIA#2222)
Signed-off-by: Yuan Chen <yuanchen97@gmail.com>
1 parent 05e5e03 commit 1e0ea13

4 files changed

Lines changed: 316 additions & 58 deletions

File tree

pkg/evidence/cncf/result_propagation_test.go

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,101 @@ case "${mode}" in
248248
autoscaler-absent)
249249
SECTION="cluster-autoscaling"
250250
;;
251+
dynamo-dispatch-list-failed)
252+
# Drives the REAL collect_service_metrics dispatcher (no override): the
253+
# Dynamo pod list fails while the namespace exists. The dispatcher must
254+
# route to the Dynamo collector so the failure surfaces, rather than
255+
# falling through to NIM/trainer and emitting unrelated evidence.
256+
kubectl() {
257+
if [ "${1:-}" = "cluster-info" ]; then
258+
return 0
259+
fi
260+
case " $* " in
261+
*"dynamo-component-type=worker"*) return 1 ;;
262+
*" get namespace dynamo-workload "*) return 0 ;;
263+
esac
264+
return 0
265+
}
266+
SECTION="service-metrics"
267+
;;
268+
dynamo-dispatch-both-queries-failed)
269+
# Both Dynamo probes fail (cluster-wide read failure: RBAC revoked,
270+
# expired credentials, apiserver outage). The namespace state cannot be
271+
# classified, so the dispatcher must NOT read that as "no workload" and
272+
# fall through to NIM/trainer — it routes to Dynamo, which fails closed.
273+
kubectl() {
274+
if [ "${1:-}" = "cluster-info" ]; then
275+
return 0
276+
fi
277+
case " $* " in
278+
*"dynamo-component-type=worker"*) return 1 ;;
279+
*" get namespace dynamo-workload "*)
280+
echo "Error from server (Forbidden): namespaces \"dynamo-workload\" is forbidden" >&2
281+
return 1 ;;
282+
esac
283+
return 0
284+
}
285+
SECTION="service-metrics"
286+
;;
287+
dynamo-workload-absent)
288+
# No worker pods: the section MEASURES an existing workload and must
289+
# never deploy one, so an absent workload is an absent prerequisite
290+
# (SKIP), not a cue to apply the manifest.
291+
kubectl() {
292+
if [ "${1:-}" = "cluster-info" ]; then
293+
return 0
294+
fi
295+
return 0
296+
}
297+
collect_service_metrics() {
298+
EVIDENCE_FILE="${EVIDENCE_DIR}/ai-service-metrics.md"
299+
collect_service_metrics_dynamo
300+
}
301+
SECTION="service-metrics"
302+
;;
303+
dynamo-workload-list-failed)
304+
# The pod list fails: a read error must fail closed rather than be
305+
# flattened into "no workload present".
306+
kubectl() {
307+
if [ "${1:-}" = "cluster-info" ]; then
308+
return 0
309+
fi
310+
case " $* " in
311+
*"dynamo-component-type=worker"*) return 1 ;;
312+
esac
313+
return 0
314+
}
315+
collect_service_metrics() {
316+
EVIDENCE_FILE="${EVIDENCE_DIR}/ai-service-metrics.md"
317+
collect_service_metrics_dynamo
318+
}
319+
SECTION="service-metrics"
320+
;;
321+
hpa-scaled-cleanup-ok|hpa-scaled-cleanup-failed)
322+
# Both lanes observe a real scale-up (replicas>1 with a current metric),
323+
# so hpa_scaled is true and the ONLY difference is whether the test
324+
# namespace could be deleted. The section must not report PASS while its
325+
# unbounded CUDA workload may still be running, so the cleanup lane has
326+
# to flip the verdict to FAIL — and the control lane proves the FAIL is
327+
# attributable to cleanup rather than to a stub that never passes.
328+
kubectl() {
329+
if [ "${1:-}" = "cluster-info" ]; then
330+
return 0
331+
fi
332+
case " $* " in
333+
*"averageValue"*) echo "75" ;;
334+
*"currentReplicas"*) echo "2" ;;
335+
esac
336+
return 0
337+
}
338+
sleep() { return 0; }
339+
if [ "${mode}" = "hpa-scaled-cleanup-failed" ]; then
340+
cleanup_ns() { return 1; }
341+
else
342+
cleanup_ns() { return 0; }
343+
fi
344+
SECTION="hpa"
345+
;;
251346
dynamo-unhealthy)
252347
kubectl() {
253348
if [ "${1:-}" = "cluster-info" ]; then
@@ -526,6 +621,58 @@ func TestEvidenceResultPropagatesThroughCollector(t *testing.T) {
526621
wantStatus: "FAIL",
527622
wantErr: true,
528623
},
624+
{
625+
name: "dispatcher routes a failed Dynamo list to the Dynamo collector",
626+
mode: "dynamo-dispatch-list-failed",
627+
displayName: "AI Service Metrics",
628+
wantStatus: "FAIL",
629+
wantEvidence: "the workload state is unknown",
630+
singleVerdictFile: "ai-service-metrics.md",
631+
wantErr: true,
632+
},
633+
{
634+
name: "unclassifiable Dynamo namespace state fails closed",
635+
mode: "dynamo-dispatch-both-queries-failed",
636+
displayName: "AI Service Metrics",
637+
wantStatus: "FAIL",
638+
wantEvidence: "the workload state is unknown",
639+
singleVerdictFile: "ai-service-metrics.md",
640+
wantErr: true,
641+
},
642+
{
643+
name: "absent Dynamo workload skips instead of deploying",
644+
mode: "dynamo-workload-absent",
645+
displayName: "AI Service Metrics",
646+
wantStatus: "SKIP",
647+
wantEvidence: "no running Dynamo workload in dynamo-workload",
648+
singleVerdictFile: "ai-service-metrics.md",
649+
},
650+
{
651+
name: "Dynamo worker pod list failure fails closed",
652+
mode: "dynamo-workload-list-failed",
653+
displayName: "AI Service Metrics",
654+
wantStatus: "FAIL",
655+
wantEvidence: "the workload state is unknown",
656+
singleVerdictFile: "ai-service-metrics.md",
657+
wantErr: true,
658+
},
659+
{
660+
name: "HPA scale-up with successful cleanup is pass",
661+
mode: "hpa-scaled-cleanup-ok",
662+
displayName: "Pod Autoscaling (HPA)",
663+
wantStatus: "PASS",
664+
singleVerdictFile: "pod-autoscaling.md",
665+
wantErr: false,
666+
},
667+
{
668+
name: "HPA scale-up with failed cleanup is fail",
669+
mode: "hpa-scaled-cleanup-failed",
670+
displayName: "Pod Autoscaling (HPA)",
671+
wantStatus: "FAIL",
672+
wantEvidence: "the hpa-test namespace could not be deleted",
673+
singleVerdictFile: "pod-autoscaling.md",
674+
wantErr: true,
675+
},
529676
{
530677
name: "present but unhealthy Dynamo is fail",
531678
mode: "dynamo-unhealthy",
@@ -598,6 +745,16 @@ func TestEvidenceResultPropagatesThroughCollector(t *testing.T) {
598745
t.Fatalf("write trainer manifest fixture: %v", err)
599746
}
600747
}
748+
if strings.HasPrefix(tt.mode, "hpa-") {
749+
manifestDir := filepath.Join(dir, "manifests")
750+
if err := os.MkdirAll(manifestDir, 0o755); err != nil {
751+
t.Fatalf("create manifest directory: %v", err)
752+
}
753+
manifestPath := filepath.Join(manifestDir, "hpa-gpu-test.yaml")
754+
if err := os.WriteFile(manifestPath, []byte("---\n"), 0o600); err != nil {
755+
t.Fatalf("write HPA manifest fixture: %v", err)
756+
}
757+
}
601758
if strings.HasPrefix(tt.mode, "gang-") {
602759
manifestDir := filepath.Join(dir, "manifests")
603760
if err := os.MkdirAll(manifestDir, 0o755); err != nil {
@@ -652,7 +809,13 @@ func TestEvidenceResultPropagatesThroughCollector(t *testing.T) {
652809
t.Errorf("result = %q, want %q", got, want)
653810
}
654811
if tt.wantEvidence != "" {
655-
evidencePath := filepath.Join(outputDir, "ai-service-metrics.md")
812+
// Assert against the artifact the case names, falling back to
813+
// the service-metrics file the original lanes all used.
814+
evidenceName := tt.singleVerdictFile
815+
if evidenceName == "" {
816+
evidenceName = "ai-service-metrics.md"
817+
}
818+
evidencePath := filepath.Join(outputDir, evidenceName)
656819
evidence, evidenceErr := os.ReadFile(evidencePath)
657820
if evidenceErr != nil {
658821
t.Fatalf("read evidence artifact: %v", evidenceErr)

0 commit comments

Comments
 (0)