@@ -21,13 +21,17 @@ import (
2121 "context"
2222 "fmt"
2323 "slices"
24+ "sort"
2425 "strings"
26+ "time"
2527
28+ "github.com/go-logr/logr"
2629 corev1 "k8s.io/api/core/v1"
2730 "k8s.io/apimachinery/pkg/api/equality"
2831 "k8s.io/apimachinery/pkg/api/errors"
2932 "k8s.io/apimachinery/pkg/api/meta"
3033 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
34+ "k8s.io/apimachinery/pkg/fields"
3135 "k8s.io/apimachinery/pkg/labels"
3236 "k8s.io/apimachinery/pkg/runtime"
3337 ctrl "sigs.k8s.io/controller-runtime"
@@ -69,6 +73,7 @@ type HypervisorController struct {
6973
7074// +kubebuilder:rbac:groups="",resources=nodes,verbs=get;list;watch
7175// +kubebuilder:rbac:groups="",resources=nodes/status,verbs=get
76+ // +kubebuilder:rbac:groups="",resources=pods,verbs=list
7277// +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors,verbs=get;list;watch;create;update;patch;delete
7378// +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors/status,verbs=get;update;patch
7479
@@ -123,17 +128,44 @@ func (hv *HypervisorController) Reconcile(ctx context.Context, req ctrl.Request)
123128 })
124129 }
125130
131+ // Only evaluate after VM eviction; a spurious True on a fresh node
132+ // (agents not yet scheduled) would be misleading.
133+ var statusRequeueAfter time.Duration
134+ if hypervisor .Spec .Maintenance == kvmv1 .MaintenanceTermination &&
135+ meta .IsStatusConditionFalse (hypervisor .Status .Conditions , kvmv1 .ConditionTypeEvicting ) &&
136+ nodeHasOffboardingTaint (node ) {
137+ cond , err := hv .computeAgentPodsEvictedCondition (ctx , log , node .Name )
138+ if err != nil {
139+ return ctrl.Result {}, fmt .Errorf ("failed to compute %s condition: %w" , kvmv1 .ConditionTypeAgentPodsEvicted , err )
140+ }
141+ meta .SetStatusCondition (& hypervisor .Status .Conditions , cond )
142+ if cond .Status == metav1 .ConditionFalse {
143+ // No pod watch — rely on periodic requeue.
144+ statusRequeueAfter = defaultPollTime
145+ }
146+ }
147+
126148 if ! equality .Semantic .DeepEqual (hypervisor , base ) {
127149 // Capture values to apply - only mutate fields this controller owns
128150 newInternalIP := hypervisor .Status .InternalIP
129151 terminatingCondition := meta .FindStatusCondition (hypervisor .Status .Conditions , kvmv1 .ConditionTypeTerminating )
152+ agentPodsCondition := meta .FindStatusCondition (hypervisor .Status .Conditions , kvmv1 .ConditionTypeAgentPodsEvicted )
130153
131- return ctrl. Result {}, utils .PatchHypervisorStatusWithRetry (ctx , hv .Client , hypervisor .Name , HypervisorControllerName , func (h * kvmv1.Hypervisor ) {
154+ if err := utils .PatchHypervisorStatusWithRetry (ctx , hv .Client , hypervisor .Name , HypervisorControllerName , func (h * kvmv1.Hypervisor ) {
132155 h .Status .InternalIP = newInternalIP
133156 if terminatingCondition != nil {
134157 meta .SetStatusCondition (& h .Status .Conditions , * terminatingCondition )
135158 }
136- })
159+ if agentPodsCondition != nil {
160+ meta .SetStatusCondition (& h .Status .Conditions , * agentPodsCondition )
161+ }
162+ }); err != nil {
163+ return ctrl.Result {}, err
164+ }
165+ return ctrl.Result {RequeueAfter : statusRequeueAfter }, nil
166+ }
167+ if statusRequeueAfter > 0 {
168+ return ctrl.Result {RequeueAfter : statusRequeueAfter }, nil
137169 }
138170
139171 syncLabelsAndAnnotations (nodeLabels , hypervisor , node )
@@ -262,3 +294,89 @@ func transportLabels(source, destination *metav1.ObjectMeta) {
262294 }
263295 }
264296}
297+
298+ // nodeHasOffboardingTaint reports whether the offboarding NoExecute taint has
299+ // been applied to the node. The pod list is only meaningful after that point —
300+ // before it, no agents have been evicted yet.
301+ func nodeHasOffboardingTaint (node * corev1.Node ) bool {
302+ for _ , t := range node .Spec .Taints {
303+ if t .Key == taintKeyOffboarding && t .Effect == corev1 .TaintEffectNoExecute {
304+ return true
305+ }
306+ }
307+ return false
308+ }
309+
310+ // computeAgentPodsEvictedCondition checks whether pods that would be evicted
311+ // by the offboarding taint are still running. Only call once Evicting=False.
312+ func (hv * HypervisorController ) computeAgentPodsEvictedCondition (ctx context.Context , log logr.Logger , nodeName string ) (metav1.Condition , error ) {
313+ offboardingTaint := corev1.Taint {
314+ Key : taintKeyOffboarding ,
315+ Effect : corev1 .TaintEffectNoExecute ,
316+ }
317+
318+ var agentPods []string
319+ for _ , ns := range global .AgentNamespaces {
320+ var continueToken string
321+ for {
322+ pods := & corev1.PodList {}
323+ if err := hv .List (ctx , pods ,
324+ k8sclient .InNamespace (ns ),
325+ k8sclient.MatchingFieldsSelector {
326+ Selector : fields .OneTermEqualSelector ("spec.nodeName" , nodeName ),
327+ },
328+ & k8sclient.ListOptions {Limit : 100 , Continue : continueToken },
329+ ); err != nil {
330+ return metav1.Condition {}, fmt .Errorf ("failed to list pods on node %s in namespace %q: %w" , nodeName , ns , err )
331+ }
332+
333+ for _ , pod := range pods .Items {
334+ if pod .Status .Phase == corev1 .PodSucceeded || pod .Status .Phase == corev1 .PodFailed {
335+ continue
336+ }
337+ if podToleratesTaint (log , & pod , & offboardingTaint ) {
338+ continue
339+ }
340+ agentPods = append (agentPods , pod .Namespace + "/" + pod .Name )
341+ }
342+
343+ if pods .Continue == "" {
344+ break
345+ }
346+ continueToken = pods .Continue
347+ }
348+ }
349+
350+ if len (agentPods ) == 0 {
351+ return metav1.Condition {
352+ Type : kvmv1 .ConditionTypeAgentPodsEvicted ,
353+ Status : metav1 .ConditionTrue ,
354+ Reason : "NoAgentPods" ,
355+ Message : "No agent pods are running on this node" ,
356+ }, nil
357+ }
358+
359+ sort .Strings (agentPods )
360+ return metav1.Condition {
361+ Type : kvmv1 .ConditionTypeAgentPodsEvicted ,
362+ Status : metav1 .ConditionFalse ,
363+ Reason : "AgentPodsRunning" ,
364+ Message : fmt .Sprintf ("%d agent pod(s) still running on node: %s" , len (agentPods ), strings .Join (agentPods , ", " )),
365+ }, nil
366+ }
367+
368+ // podToleratesTaint reports whether the pod tolerates the taint indefinitely.
369+ // Tolerations with a finite TolerationSeconds are excluded: the pod will
370+ // eventually be evicted and must not be treated as safe to ignore.
371+ func podToleratesTaint (log logr.Logger , pod * corev1.Pod , taint * corev1.Taint ) bool {
372+ for i := range pod .Spec .Tolerations {
373+ t := & pod .Spec .Tolerations [i ]
374+ if t .TolerationSeconds != nil {
375+ continue
376+ }
377+ if t .ToleratesTaint (log , taint , false ) {
378+ return true
379+ }
380+ }
381+ return false
382+ }
0 commit comments