@@ -18,9 +18,13 @@ limitations under the License.
1818package controller
1919
2020import (
21+ "fmt"
22+ "sync/atomic"
23+
2124 . "github.com/onsi/ginkgo/v2"
2225 . "github.com/onsi/gomega"
2326 corev1 "k8s.io/api/core/v1"
27+ "k8s.io/apimachinery/pkg/api/meta"
2428 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2529 "k8s.io/apimachinery/pkg/types"
2630 ctrl "sigs.k8s.io/controller-runtime"
@@ -29,6 +33,10 @@ import (
2933 kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1"
3034)
3135
36+ // envtest does not actually GC pods when their namespace is deleted, so
37+ // each spec gets a fresh namespace via this counter to keep them isolated.
38+ var agentNamespaceCounter atomic.Uint64
39+
3240var _ = Describe ("Hypervisor Controller" , func () {
3341 const (
3442 resourceName = "other-node"
@@ -402,4 +410,164 @@ var _ = Describe("Hypervisor Controller", func() {
402410 Expect (hypervisor .Status .InternalIP ).To (Equal ("192.168.1.100" ))
403411 })
404412 })
413+
414+ Context ("AgentPodsEvicted condition" , func () {
415+ var agentNamespace string
416+
417+ BeforeEach (func (ctx SpecContext ) {
418+ agentNamespace = fmt .Sprintf ("agent-ns-%d" , agentNamespaceCounter .Add (1 ))
419+ ns := & corev1.Namespace {ObjectMeta : metav1.ObjectMeta {Name : agentNamespace }}
420+ Expect (client .IgnoreAlreadyExists (k8sClient .Create (ctx , ns ))).To (Succeed ())
421+ DeferCleanup (func (ctx SpecContext ) {
422+ Expect (client .IgnoreNotFound (k8sClient .Delete (ctx , ns ))).To (Succeed ())
423+ })
424+
425+ // The condition is only computed during termination.
426+ _ , err := hypervisorController .Reconcile (ctx , ctrl.Request {
427+ NamespacedName : types.NamespacedName {Name : resource .Name },
428+ })
429+ Expect (err ).NotTo (HaveOccurred ())
430+
431+ hypervisor := & kvmv1.Hypervisor {}
432+ Expect (k8sClient .Get (ctx , hypervisorName , hypervisor )).To (Succeed ())
433+ hypervisor .Spec .Maintenance = kvmv1 .MaintenanceTermination
434+ hypervisor .Spec .LifecycleEnabled = true
435+ Expect (k8sClient .Update (ctx , hypervisor )).To (Succeed ())
436+
437+ // Default for these specs: VM eviction is done. Subcontexts
438+ // that exercise the "not yet done" path override this.
439+ Expect (k8sClient .Get (ctx , hypervisorName , hypervisor )).To (Succeed ())
440+ meta .SetStatusCondition (& hypervisor .Status .Conditions , metav1.Condition {
441+ Type : kvmv1 .ConditionTypeEvicting ,
442+ Status : metav1 .ConditionFalse ,
443+ Reason : "Succeeded" ,
444+ Message : "All VMs evicted" ,
445+ })
446+ Expect (k8sClient .Status ().Update (ctx , hypervisor )).To (Succeed ())
447+ })
448+
449+ createPod := func (ctx SpecContext , name , namespace string , phase corev1.PodPhase , tolerations ... corev1.Toleration ) * corev1.Pod {
450+ pod := & corev1.Pod {
451+ ObjectMeta : metav1.ObjectMeta {
452+ Name : name ,
453+ Namespace : namespace ,
454+ },
455+ Spec : corev1.PodSpec {
456+ NodeName : resource .Name ,
457+ Containers : []corev1.Container {
458+ {Name : "main" , Image : "registry.example.com/whatever:latest" },
459+ },
460+ Tolerations : tolerations ,
461+ },
462+ Status : corev1.PodStatus {Phase : phase },
463+ }
464+ Expect (k8sClient .Create (ctx , pod )).To (Succeed ())
465+ Expect (k8sClient .Get (ctx , types.NamespacedName {Name : name , Namespace : namespace }, pod )).To (Succeed ())
466+ pod .Status .Phase = phase
467+ Expect (k8sClient .Status ().Update (ctx , pod )).To (Succeed ())
468+ DeferCleanup (func (ctx SpecContext ) {
469+ Expect (client .IgnoreNotFound (k8sClient .Delete (ctx , pod ))).To (Succeed ())
470+ })
471+ return pod
472+ }
473+
474+ When ("only pods that tolerate the offboarding taint are running" , func () {
475+ BeforeEach (func (ctx SpecContext ) {
476+ createPod (ctx , "tolerator" , agentNamespace , corev1 .PodRunning , corev1.Toleration {
477+ Key : taintKeyOffboarding ,
478+ Operator : corev1 .TolerationOpExists ,
479+ Effect : corev1 .TaintEffectNoExecute ,
480+ })
481+ // Phase=Succeeded must not count regardless of tolerations.
482+ createPod (ctx , "old-job" , agentNamespace , corev1 .PodSucceeded )
483+ })
484+
485+ It ("should set AgentPodsEvicted=True without requeue" , func (ctx SpecContext ) {
486+ result , err := hypervisorController .Reconcile (ctx , ctrl.Request {
487+ NamespacedName : types.NamespacedName {Name : resource .Name },
488+ })
489+ Expect (err ).NotTo (HaveOccurred ())
490+ Expect (result .RequeueAfter ).To (BeZero ())
491+
492+ hypervisor := & kvmv1.Hypervisor {}
493+ Expect (k8sClient .Get (ctx , hypervisorName , hypervisor )).To (Succeed ())
494+ Expect (hypervisor .Status .Conditions ).To (ContainElement (
495+ SatisfyAll (
496+ HaveField ("Type" , kvmv1 .ConditionTypeAgentPodsEvicted ),
497+ HaveField ("Status" , metav1 .ConditionTrue ),
498+ HaveField ("Reason" , "NoAgentPods" ),
499+ ),
500+ ))
501+ })
502+ })
503+
504+ When ("an agent pod is running on the node and VM eviction is done" , func () {
505+ BeforeEach (func (ctx SpecContext ) {
506+ createPod (ctx , "nova-compute-xyz" , agentNamespace , corev1 .PodRunning )
507+ })
508+
509+ It ("should set AgentPodsEvicted=False with reason AgentPodsRunning and requeue" , func (ctx SpecContext ) {
510+ result , err := hypervisorController .Reconcile (ctx , ctrl.Request {
511+ NamespacedName : types.NamespacedName {Name : resource .Name },
512+ })
513+ Expect (err ).NotTo (HaveOccurred ())
514+ Expect (result .RequeueAfter ).To (Equal (defaultPollTime ))
515+
516+ hypervisor := & kvmv1.Hypervisor {}
517+ Expect (k8sClient .Get (ctx , hypervisorName , hypervisor )).To (Succeed ())
518+ Expect (hypervisor .Status .Conditions ).To (ContainElement (
519+ SatisfyAll (
520+ HaveField ("Type" , kvmv1 .ConditionTypeAgentPodsEvicted ),
521+ HaveField ("Status" , metav1 .ConditionFalse ),
522+ HaveField ("Reason" , "AgentPodsRunning" ),
523+ HaveField ("Message" , ContainSubstring ("nova-compute-xyz" )),
524+ ),
525+ ))
526+ })
527+ })
528+
529+ When ("the only non-tolerating pod is already being deleted" , func () {
530+ // A finalizer keeps the API object around with DeletionTimestamp
531+ // set, simulating a pod whose containers are shutting down but
532+ // whose deletion is blocked on some unrelated finalizer.
533+ const finalizer = "test.kvm.cloud.sap/keep-alive"
534+
535+ BeforeEach (func (ctx SpecContext ) {
536+ pod := createPod (ctx , "nova-compute-deleting" , agentNamespace , corev1 .PodRunning )
537+ pod .Finalizers = []string {finalizer }
538+ Expect (k8sClient .Update (ctx , pod )).To (Succeed ())
539+ Expect (k8sClient .Delete (ctx , pod )).To (Succeed ())
540+
541+ DeferCleanup (func (ctx SpecContext ) {
542+ fresh := & corev1.Pod {}
543+ if err := k8sClient .Get (ctx , types.NamespacedName {Name : pod .Name , Namespace : pod .Namespace }, fresh ); err == nil {
544+ fresh .Finalizers = nil
545+ Expect (client .IgnoreNotFound (k8sClient .Update (ctx , fresh ))).To (Succeed ())
546+ }
547+ })
548+
549+ // Sanity: the pod must still exist with DeletionTimestamp.
550+ fresh := & corev1.Pod {}
551+ Expect (k8sClient .Get (ctx , types.NamespacedName {Name : pod .Name , Namespace : pod .Namespace }, fresh )).To (Succeed ())
552+ Expect (fresh .DeletionTimestamp ).NotTo (BeNil ())
553+ })
554+
555+ It ("should set AgentPodsEvicted=True (deletion-pending pod is treated as gone)" , func (ctx SpecContext ) {
556+ _ , err := hypervisorController .Reconcile (ctx , ctrl.Request {
557+ NamespacedName : types.NamespacedName {Name : resource .Name },
558+ })
559+ Expect (err ).NotTo (HaveOccurred ())
560+
561+ hypervisor := & kvmv1.Hypervisor {}
562+ Expect (k8sClient .Get (ctx , hypervisorName , hypervisor )).To (Succeed ())
563+ Expect (hypervisor .Status .Conditions ).To (ContainElement (
564+ SatisfyAll (
565+ HaveField ("Type" , kvmv1 .ConditionTypeAgentPodsEvicted ),
566+ HaveField ("Status" , metav1 .ConditionTrue ),
567+ HaveField ("Reason" , "NoAgentPods" ),
568+ ),
569+ ))
570+ })
571+ })
572+ })
405573})
0 commit comments