@@ -467,6 +467,208 @@ test_snapshot() {
467467 fi
468468}
469469
470+ # =============================================================================
471+ # Snapshot Run Isolation Tests (ADR-020, issue #2120)
472+ # =============================================================================
473+
474+ # Verifies that concurrent snapshot runs own and delete only their own
475+ # Kubernetes resources. These checks are cluster-backed on purpose: the
476+ # unit tests in pkg/k8s/agent run against a fake clientset, which runs no
477+ # Job controller (so pod ownerReferences must be hand-seeded there) and does
478+ # not enforce metav1.Preconditions on delete. Only a real apiserver exercises
479+ # both.
480+ #
481+ # Scope note on UID preconditions: the delete-with-stale-UID race itself is
482+ # not reachable from outside the CLI process — the window between an object's
483+ # creation and the deferred Cleanup is the Job wait, and swapping a live
484+ # object's UID mid-run revokes the running agent's own credentials. That
485+ # mechanism is covered by TestCleanupPassesUIDPrecondition and
486+ # TestCleanupTreatsConflictAsSuccess in pkg/k8s/agent/deployer_test.go. What
487+ # is verified here is the ownership contract those preconditions enforce and
488+ # that IS externally observable: cleanup deletes only objects this run
489+ # created, never one that merely matches its labels or name shape.
490+ test_snapshot_run_isolation () {
491+ msg " =========================================="
492+ msg " Testing snapshot run isolation"
493+ msg " =========================================="
494+
495+ if [ " $FAKE_GPU_ENABLED " != " true" ]; then
496+ skip " snapshot/isolation" " Fake GPU not enabled"
497+ return 0
498+ fi
499+
500+ local ns=" $SNAPSHOT_NAMESPACE "
501+ local decoy=" aicr-node-reader-e2edecoy"
502+ local rc=0
503+
504+ # --- Decoy: labelled like a run-owned object, but created by nobody's run ---
505+ # A cleanup that swept by label or by name shape would collect this. A
506+ # cleanup scoped to its own created-set must leave it alone.
507+ msg " --- Test: cleanup is scoped to created objects, not to labels ---"
508+ kubectl delete clusterrole " $decoy " --ignore-not-found=true > /dev/null 2>&1 || true
509+ kubectl create clusterrole " $decoy " --verb=get --resource=nodes > /dev/null 2>&1 || true
510+ kubectl label clusterrole " $decoy " \
511+ app.kubernetes.io/name=aicr \
512+ app.kubernetes.io/managed-by=aicr \
513+ app.kubernetes.io/component=snapshot-agent \
514+ aicr.run/run-id=e2edecoy --overwrite > /dev/null 2>&1 || true
515+ local decoy_uid_before
516+ decoy_uid_before=$( kubectl get clusterrole " $decoy " -o jsonpath=' {.metadata.uid}' 2> /dev/null || echo " " )
517+
518+ # --- Retained run: --no-cleanup, so its objects must outlive later runs ---
519+ msg " --- Test: a retained run's resources survive concurrent runs ---"
520+ local retained_log=" ${OUTPUT_DIR} /isolation-retained.log"
521+ " ${AICR_BIN} " snapshot \
522+ --image " ${AICR_IMAGE} " \
523+ --namespace " ${ns} " \
524+ --no-cleanup \
525+ --output " ${OUTPUT_DIR} /isolation-retained.yaml" \
526+ --timeout 180s \
527+ --privileged \
528+ --node-selector kubernetes.io/os=linux > " $retained_log " 2>&1 || rc=$?
529+
530+ if [ " $rc " -ne 0 ]; then
531+ cat " $retained_log "
532+ fail " snapshot/isolation/retained-run" " retained snapshot run failed"
533+ return 1
534+ fi
535+
536+ local retained_id
537+ retained_id=$( kubectl get jobs -n " $ns " -l app.kubernetes.io/name=aicr \
538+ -o jsonpath=' {.items[0].metadata.labels.aicr\.run/run-id}' 2> /dev/null || echo " " )
539+ if [ -z " $retained_id " ]; then
540+ fail " snapshot/isolation/run-id-label" " no aicr.run/run-id label on the retained Job"
541+ return 1
542+ fi
543+ detail " retained run ID: ${retained_id} "
544+ pass " snapshot/isolation/run-id-label"
545+
546+ # Every run-owned object must carry the run ID in its name.
547+ local missing=" "
548+ kubectl get job -n " $ns " " aicr-${retained_id} " > /dev/null 2>&1 || missing=" ${missing} job"
549+ kubectl get sa -n " $ns " " aicr-${retained_id} " > /dev/null 2>&1 || missing=" ${missing} sa"
550+ kubectl get role -n " $ns " " aicr-${retained_id} " > /dev/null 2>&1 || missing=" ${missing} role"
551+ kubectl get rolebinding -n " $ns " " aicr-${retained_id} " > /dev/null 2>&1 || missing=" ${missing} rolebinding"
552+ kubectl get cm -n " $ns " " aicr-agent-snapshot-${retained_id} " > /dev/null 2>&1 || missing=" ${missing} staging-cm"
553+ kubectl get clusterrole " aicr-node-reader-${retained_id} " > /dev/null 2>&1 || missing=" ${missing} clusterrole"
554+ kubectl get clusterrolebinding " aicr-node-reader-${retained_id} " > /dev/null 2>&1 || missing=" ${missing} clusterrolebinding"
555+ if [ -n " $missing " ]; then
556+ fail " snapshot/isolation/run-scoped-names" " not found under run-scoped names:${missing} "
557+ return 1
558+ fi
559+ pass " snapshot/isolation/run-scoped-names"
560+
561+ # The agent's staging ConfigMap must not reuse the validator's name shape.
562+ # aicr validate hands one run ID to both subsystems in one namespace, so a
563+ # shared aicr-snapshot- prefix would give two owners one object.
564+ if kubectl get cm -n " $ns " " aicr-snapshot-${retained_id} " > /dev/null 2>&1 ; then
565+ fail " snapshot/isolation/staging-cm-name" " found aicr-snapshot-${retained_id} ; collides with the validator's data ConfigMap"
566+ return 1
567+ fi
568+ pass " snapshot/isolation/staging-cm-name"
569+
570+ # The pod must be authorized by its controlling ownerReference, not by a
571+ # label — pod labels are writable by anything that can update pods.
572+ msg " --- Test: pod is owned by its Job (controlling ownerReference) ---"
573+ local pod job_uid owner_uid
574+ pod=$( kubectl get pods -n " $ns " -l " aicr.run/run-id=${retained_id} " \
575+ -o jsonpath=' {.items[0].metadata.name}' 2> /dev/null || echo " " )
576+ job_uid=$( kubectl get job -n " $ns " " aicr-${retained_id} " -o jsonpath=' {.metadata.uid}' 2> /dev/null || echo " " )
577+ owner_uid=$( kubectl get pod -n " $ns " " $pod " \
578+ -o jsonpath=' {.metadata.ownerReferences[?(@.controller==true)].uid}' 2> /dev/null || echo " " )
579+ if [ -n " $pod " ] && [ -n " $job_uid " ] && [ " $job_uid " = " $owner_uid " ]; then
580+ detail " pod ${pod} controlled by Job UID ${job_uid} "
581+ pass " snapshot/isolation/pod-owned-by-job"
582+ else
583+ fail " snapshot/isolation/pod-owned-by-job" " pod=${pod} jobUID=${job_uid} ownerUID=${owner_uid} "
584+ return 1
585+ fi
586+
587+ # --- Two concurrent runs, with the retained run's objects still present ---
588+ msg " --- Test: two concurrent runs are independent ---"
589+ local log_a=" ${OUTPUT_DIR} /isolation-a.log"
590+ local log_b=" ${OUTPUT_DIR} /isolation-b.log"
591+ local rc_a=0 rc_b=0 pid_a pid_b
592+
593+ " ${AICR_BIN} " snapshot --image " ${AICR_IMAGE} " --namespace " ${ns} " \
594+ --output " ${OUTPUT_DIR} /isolation-a.yaml" --timeout 180s --privileged \
595+ --node-selector kubernetes.io/os=linux > " $log_a " 2>&1 &
596+ pid_a=$!
597+ " ${AICR_BIN} " snapshot --image " ${AICR_IMAGE} " --namespace " ${ns} " \
598+ --output " ${OUTPUT_DIR} /isolation-b.yaml" --timeout 180s --privileged \
599+ --node-selector kubernetes.io/os=linux > " $log_b " 2>&1 &
600+ pid_b=$!
601+
602+ wait " $pid_a " || rc_a=$?
603+ wait " $pid_b " || rc_b=$?
604+
605+ if [ " $rc_a " -ne 0 ] || [ " $rc_b " -ne 0 ]; then
606+ echo " --- concurrent run A ---" ; tail -30 " $log_a "
607+ echo " --- concurrent run B ---" ; tail -30 " $log_b "
608+ fail " snapshot/isolation/concurrent-runs" " run A rc=${rc_a} , run B rc=${rc_b} "
609+ return 1
610+ fi
611+
612+ local id_a id_b
613+ id_a=$( grep -o ' runID=[0-9a-f-]*' " $log_a " | head -1 | cut -d= -f2 || echo " " )
614+ id_b=$( grep -o ' runID=[0-9a-f-]*' " $log_b " | head -1 | cut -d= -f2 || echo " " )
615+ if [ -z " $id_a " ] || [ -z " $id_b " ] || [ " $id_a " = " $id_b " ]; then
616+ fail " snapshot/isolation/concurrent-runs" " expected two distinct run IDs, got '${id_a} ' and '${id_b} '"
617+ return 1
618+ fi
619+ detail " run A: ${id_a} "
620+ detail " run B: ${id_b} "
621+ if [ ! -s " ${OUTPUT_DIR} /isolation-a.yaml" ] || [ ! -s " ${OUTPUT_DIR} /isolation-b.yaml" ]; then
622+ fail " snapshot/isolation/concurrent-runs" " a concurrent run produced an empty snapshot"
623+ return 1
624+ fi
625+ pass " snapshot/isolation/concurrent-runs"
626+
627+ # The retained run's objects must be untouched by both concurrent runs.
628+ # Before ADR-020 this is exactly what broke: a second run's ensureJob
629+ # deleted the same-named Job and its cleanup deleted the shared RBAC.
630+ local destroyed=" "
631+ kubectl get job -n " $ns " " aicr-${retained_id} " > /dev/null 2>&1 || destroyed=" ${destroyed} job"
632+ kubectl get sa -n " $ns " " aicr-${retained_id} " > /dev/null 2>&1 || destroyed=" ${destroyed} sa"
633+ kubectl get role -n " $ns " " aicr-${retained_id} " > /dev/null 2>&1 || destroyed=" ${destroyed} role"
634+ kubectl get rolebinding -n " $ns " " aicr-${retained_id} " > /dev/null 2>&1 || destroyed=" ${destroyed} rolebinding"
635+ kubectl get clusterrole " aicr-node-reader-${retained_id} " > /dev/null 2>&1 || destroyed=" ${destroyed} clusterrole"
636+ kubectl get clusterrolebinding " aicr-node-reader-${retained_id} " > /dev/null 2>&1 || destroyed=" ${destroyed} clusterrolebinding"
637+ if [ -n " $destroyed " ]; then
638+ fail " snapshot/isolation/retained-run-survives" " concurrent runs destroyed:${destroyed} "
639+ return 1
640+ fi
641+ pass " snapshot/isolation/retained-run-survives"
642+
643+ # Each concurrent run must have removed its own resources.
644+ local leftover_jobs
645+ leftover_jobs=$( kubectl get jobs -n " $ns " -l app.kubernetes.io/name=aicr -o name 2> /dev/null | wc -l | tr -d ' ' )
646+ if [ " $leftover_jobs " != " 1" ]; then
647+ kubectl get jobs -n " $ns " -l app.kubernetes.io/name=aicr -o name || true
648+ fail " snapshot/isolation/self-cleanup" " expected only the retained Job to remain, found ${leftover_jobs} "
649+ return 1
650+ fi
651+ pass " snapshot/isolation/self-cleanup"
652+
653+ # The decoy must have survived every run above.
654+ local decoy_uid_after
655+ decoy_uid_after=$( kubectl get clusterrole " $decoy " -o jsonpath=' {.metadata.uid}' 2> /dev/null || echo " " )
656+ if [ -n " $decoy_uid_before " ] && [ " $decoy_uid_before " = " $decoy_uid_after " ]; then
657+ pass " snapshot/isolation/cleanup-scoped-to-created"
658+ else
659+ fail " snapshot/isolation/cleanup-scoped-to-created" \
660+ " aicr-labelled ClusterRole it never created was deleted or replaced (before=${decoy_uid_before} after=${decoy_uid_after} )"
661+ return 1
662+ fi
663+
664+ # Housekeeping: remove the decoy and the retained run's resources.
665+ kubectl delete clusterrole " $decoy " --ignore-not-found=true > /dev/null 2>&1 || true
666+ kubectl delete job " aicr-${retained_id} " -n " $ns " --ignore-not-found=true > /dev/null 2>&1 || true
667+ kubectl delete sa,role,rolebinding " aicr-${retained_id} " -n " $ns " --ignore-not-found=true > /dev/null 2>&1 || true
668+ kubectl delete cm " aicr-agent-snapshot-${retained_id} " -n " $ns " --ignore-not-found=true > /dev/null 2>&1 || true
669+ kubectl delete clusterrole,clusterrolebinding " aicr-node-reader-${retained_id} " --ignore-not-found=true > /dev/null 2>&1 || true
670+ }
671+
470672# =============================================================================
471673# Recipe from Snapshot Tests (from e2e.md)
472674# =============================================================================
@@ -1689,6 +1891,7 @@ main() {
16891891 # Setup fake GPU environment and run snapshot tests
16901892 if setup_fake_gpu; then
16911893 test_snapshot
1894+ test_snapshot_run_isolation
16921895 test_recipe_from_snapshot
16931896 test_validate
16941897 test_validate_deployment_checks
0 commit comments