@@ -411,4 +411,167 @@ var _ = framework.KubeDescribe("NRI", func() {
411411 }
412412 })
413413 })
414+
415+ Context ("StopPodSandbox contract" , Serial , func () {
416+ var (
417+ testStub * NRITestStub
418+ podID string
419+ podConfig * runtimeapi.PodSandboxConfig
420+ containerID string
421+ )
422+
423+ AfterEach (func (ctx SpecContext ) {
424+ // Stop the stub first to unblock any hooks that may be holding
425+ // a StopPodSandbox call, allowing it to complete.
426+ if testStub != nil {
427+ testStub .Cleanup ()
428+ }
429+
430+ if containerID != "" {
431+ if err := rc .StopContainer (ctx , containerID , 0 ); err != nil {
432+ framework .Logf ("AfterEach: StopContainer(%s) failed: %v" , containerID , err )
433+ }
434+
435+ if err := rc .RemoveContainer (ctx , containerID ); err != nil {
436+ framework .Logf ("AfterEach: RemoveContainer(%s) failed: %v" , containerID , err )
437+ }
438+ }
439+
440+ if podID != "" {
441+ if err := rc .StopPodSandbox (ctx , podID ); err != nil {
442+ framework .Logf ("AfterEach: StopPodSandbox(%s) failed: %v" , podID , err )
443+ }
444+
445+ if err := rc .RemovePodSandbox (ctx , podID ); err != nil {
446+ framework .Logf ("AfterEach: RemovePodSandbox(%s) failed: %v" , podID , err )
447+ }
448+ }
449+ })
450+
451+ It ("should stop all workload containers and keep sandbox accessible while StopPodSandbox hook is in progress" , func (ctx SpecContext ) {
452+ // This test validates the spec contract: when StopPodSandbox is called,
453+ // all workload containers MUST already be stopped before the NRI hook
454+ // fires, and the sandbox infrastructure MUST still be accessible via
455+ // PodSandboxStatus while the hook is in progress.
456+ hookBlocking := make (chan struct {})
457+ hookReached := make (chan struct {})
458+
459+ var hookOnce sync.Once
460+
461+ var err error
462+
463+ testStub , err = StartNRITestStub ("cri-test-nri-stop-state" , "00" )
464+ Expect (err ).NotTo (HaveOccurred (), "failed to start NRI test stub" )
465+
466+ // Configure stub to block during StopPodSandbox so the main
467+ // goroutine can inspect runtime state while the hook is active.
468+ // sync.Once guards against the hook being invoked multiple times
469+ // (e.g., idempotent stop redelivery), which would otherwise panic
470+ // on a double close of hookReached.
471+ testStub .Plugin .OnStopPodSandbox = func (hookCtx context.Context , _ * nri.PodSandbox ) error {
472+ firstInvocation := false
473+
474+ hookOnce .Do (func () { firstInvocation = true })
475+
476+ // Skip duplicate invocations (e.g., AfterEach cleanup) so
477+ // they are not blocked by the test channel handshake.
478+ if ! firstInvocation {
479+ return nil
480+ }
481+
482+ close (hookReached )
483+
484+ select {
485+ case <- hookBlocking :
486+ case <- hookCtx .Done ():
487+ }
488+
489+ return nil
490+ }
491+
492+ By ("creating a pod sandbox" )
493+
494+ podSandboxName := "nri-test-stop-state-" + framework .NewUUID ()
495+ uid := framework .DefaultUIDPrefix + framework .NewUUID ()
496+ namespace := framework .DefaultNamespacePrefix + framework .NewUUID ()
497+ podConfig = & runtimeapi.PodSandboxConfig {
498+ Metadata : framework .BuildPodSandboxMetadata (podSandboxName , uid , namespace , framework .DefaultAttempt ),
499+ Linux : & runtimeapi.LinuxPodSandboxConfig {
500+ CgroupParent : common .GetCgroupParent (ctx , rc ),
501+ },
502+ Labels : framework .DefaultPodLabels ,
503+ }
504+ podID = framework .RunPodSandbox (ctx , rc , podConfig )
505+ Expect (podID ).NotTo (BeEmpty ())
506+
507+ By ("creating and starting a container in the sandbox" )
508+ framework .PullPublicImage (ctx , ic , framework .TestContext .TestImageList .DefaultTestContainerImage , nil )
509+
510+ containerName := "nri-test-stop-state-ctr-" + framework .NewUUID ()
511+ containerConfig := & runtimeapi.ContainerConfig {
512+ Metadata : framework .BuildContainerMetadata (containerName , framework .DefaultAttempt ),
513+ Image : & runtimeapi.ImageSpec {Image : framework .TestContext .TestImageList .DefaultTestContainerImage },
514+ Command : framework .DefaultPauseCommand ,
515+ Linux : & runtimeapi.LinuxContainerConfig {},
516+ }
517+ containerID = framework .CreateContainer (ctx , rc , ic , containerConfig , podID , podConfig )
518+ Expect (containerID ).NotTo (BeEmpty ())
519+ Expect (rc .StartContainer (ctx , containerID )).NotTo (HaveOccurred ())
520+
521+ By ("triggering StopPodSandbox in a goroutine" )
522+
523+ var (
524+ stopErr error
525+ stopWg sync.WaitGroup
526+ )
527+
528+ stopWg .Go (func () {
529+ stopErr = rc .StopPodSandbox (ctx , podID )
530+ })
531+
532+ By ("waiting for StopPodSandbox hook to be reached" )
533+
534+ select {
535+ case <- hookReached :
536+ // Hook is now blocking; main goroutine can inspect state
537+ case <- time .After (30 * time .Second ):
538+ close (hookBlocking ) // unblock to avoid goroutine leak
539+ Fail ("Timed out waiting for StopPodSandbox NRI hook to fire" )
540+ }
541+
542+ By ("verifying all workload containers were stopped before the hook fired" )
543+
544+ containers , listErr := rc .ListContainers (ctx , & runtimeapi.ContainerFilter {
545+ PodSandboxId : podID ,
546+ })
547+ Expect (listErr ).NotTo (HaveOccurred (), "ListContainers during StopPodSandbox hook" )
548+
549+ for _ , c := range containers {
550+ Expect (c .GetState ()).To (Equal (runtimeapi .ContainerState_CONTAINER_EXITED ),
551+ "container %s MUST be stopped before StopPodSandbox hook fires" , c .GetId ())
552+ }
553+
554+ By ("verifying sandbox infrastructure is still accessible during the hook" )
555+
556+ statusResp , statusErr := rc .PodSandboxStatus (ctx , podID , false )
557+ Expect (statusErr ).NotTo (HaveOccurred (), "PodSandboxStatus during StopPodSandbox hook" )
558+ Expect (statusResp .GetStatus ()).NotTo (BeNil (),
559+ "PodSandboxStatus MUST be accessible during StopPodSandbox hook" )
560+ Expect (statusResp .GetStatus ().GetId ()).To (Equal (podID ))
561+
562+ By ("releasing the hook and verifying StopPodSandbox succeeds" )
563+ close (hookBlocking )
564+ stopWg .Wait ()
565+ Expect (stopErr ).NotTo (HaveOccurred (), "StopPodSandbox should succeed after hook returns" )
566+
567+ // StopPodSandbox stopped the container; clear so AfterEach skips it.
568+ containerID = ""
569+
570+ // Remove the sandbox now and clear podID so AfterEach doesn't issue a
571+ // second StopPodSandbox (which could re-enter the hook callback).
572+ Expect (rc .RemovePodSandbox (ctx , podID )).NotTo (HaveOccurred (),
573+ "RemovePodSandbox should succeed after stop" )
574+ podID = ""
575+ })
576+ })
414577})
0 commit comments