@@ -21,13 +21,16 @@ import (
2121 "context"
2222 "fmt"
2323 "slices"
24+ "sort"
2425 "strings"
2526
27+ "github.com/go-logr/logr"
2628 corev1 "k8s.io/api/core/v1"
2729 "k8s.io/apimachinery/pkg/api/equality"
2830 "k8s.io/apimachinery/pkg/api/errors"
2931 "k8s.io/apimachinery/pkg/api/meta"
3032 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33+ "k8s.io/apimachinery/pkg/fields"
3134 "k8s.io/apimachinery/pkg/labels"
3235 "k8s.io/apimachinery/pkg/runtime"
3336 ctrl "sigs.k8s.io/controller-runtime"
@@ -69,6 +72,7 @@ type HypervisorController struct {
6972
7073// +kubebuilder:rbac:groups="",resources=nodes,verbs=get;list;watch
7174// +kubebuilder:rbac:groups="",resources=nodes/status,verbs=get
75+ // +kubebuilder:rbac:groups="",resources=pods,verbs=list
7276// +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors,verbs=get;list;watch;create;update;patch;delete
7377// +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors/status,verbs=get;update;patch
7478
@@ -123,16 +127,41 @@ func (hv *HypervisorController) Reconcile(ctx context.Context, req ctrl.Request)
123127 })
124128 }
125129
130+ // Only evaluate after VM eviction; a spurious True on a fresh node
131+ // (agents not yet scheduled) would be misleading.
132+ if hypervisor .Spec .Maintenance == kvmv1 .MaintenanceTermination &&
133+ meta .IsStatusConditionFalse (hypervisor .Status .Conditions , kvmv1 .ConditionTypeEvicting ) &&
134+ nodeHasOffboardingTaint (node ) {
135+ cond , err := hv .computeAgentPodsEvictedCondition (ctx , log , node .Name )
136+ if err != nil {
137+ return ctrl.Result {}, fmt .Errorf ("failed to compute %s condition: %w" , kvmv1 .ConditionTypeAgentPodsEvicted , err )
138+ }
139+ meta .SetStatusCondition (& hypervisor .Status .Conditions , cond )
140+ if cond .Status == metav1 .ConditionFalse {
141+ // No pod watch — rely on periodic requeue.
142+ if err := utils .PatchHypervisorStatusWithRetry (ctx , hv .Client , hypervisor .Name , HypervisorControllerName , func (h * kvmv1.Hypervisor ) {
143+ meta .SetStatusCondition (& h .Status .Conditions , cond )
144+ }); err != nil {
145+ return ctrl.Result {}, err
146+ }
147+ return ctrl.Result {RequeueAfter : defaultPollTime }, nil
148+ }
149+ }
150+
126151 if ! equality .Semantic .DeepEqual (hypervisor , base ) {
127152 // Capture values to apply - only mutate fields this controller owns
128153 newInternalIP := hypervisor .Status .InternalIP
129154 terminatingCondition := meta .FindStatusCondition (hypervisor .Status .Conditions , kvmv1 .ConditionTypeTerminating )
155+ agentPodsCondition := meta .FindStatusCondition (hypervisor .Status .Conditions , kvmv1 .ConditionTypeAgentPodsEvicted )
130156
131157 return ctrl.Result {}, utils .PatchHypervisorStatusWithRetry (ctx , hv .Client , hypervisor .Name , HypervisorControllerName , func (h * kvmv1.Hypervisor ) {
132158 h .Status .InternalIP = newInternalIP
133159 if terminatingCondition != nil {
134160 meta .SetStatusCondition (& h .Status .Conditions , * terminatingCondition )
135161 }
162+ if agentPodsCondition != nil {
163+ meta .SetStatusCondition (& h .Status .Conditions , * agentPodsCondition )
164+ }
136165 })
137166 }
138167
@@ -262,3 +291,89 @@ func transportLabels(source, destination *metav1.ObjectMeta) {
262291 }
263292 }
264293}
294+
295+ // nodeHasOffboardingTaint reports whether the offboarding NoExecute taint has
296+ // been applied to the node. The pod list is only meaningful after that point —
297+ // before it, no agents have been evicted yet.
298+ func nodeHasOffboardingTaint (node * corev1.Node ) bool {
299+ for _ , t := range node .Spec .Taints {
300+ if t .Key == taintKeyOffboarding && t .Effect == corev1 .TaintEffectNoExecute {
301+ return true
302+ }
303+ }
304+ return false
305+ }
306+
307+ // computeAgentPodsEvictedCondition checks whether pods that would be evicted
308+ // by the offboarding taint are still running. Only call once Evicting=False.
309+ func (hv * HypervisorController ) computeAgentPodsEvictedCondition (ctx context.Context , log logr.Logger , nodeName string ) (metav1.Condition , error ) {
310+ offboardingTaint := corev1.Taint {
311+ Key : taintKeyOffboarding ,
312+ Effect : corev1 .TaintEffectNoExecute ,
313+ }
314+
315+ var agentPods []string
316+ for _ , ns := range global .AgentNamespaces {
317+ var continueToken string
318+ for {
319+ pods := & corev1.PodList {}
320+ if err := hv .List (ctx , pods ,
321+ k8sclient .InNamespace (ns ),
322+ k8sclient.MatchingFieldsSelector {
323+ Selector : fields .OneTermEqualSelector ("spec.nodeName" , nodeName ),
324+ },
325+ & k8sclient.ListOptions {Limit : 100 , Continue : continueToken },
326+ ); err != nil {
327+ return metav1.Condition {}, fmt .Errorf ("failed to list pods on node %s in namespace %q: %w" , nodeName , ns , err )
328+ }
329+
330+ for _ , pod := range pods .Items {
331+ if pod .Status .Phase == corev1 .PodSucceeded || pod .Status .Phase == corev1 .PodFailed {
332+ continue
333+ }
334+ if podToleratesTaint (log , & pod , & offboardingTaint ) {
335+ continue
336+ }
337+ agentPods = append (agentPods , pod .Namespace + "/" + pod .Name )
338+ }
339+
340+ if pods .Continue == "" {
341+ break
342+ }
343+ continueToken = pods .Continue
344+ }
345+ }
346+
347+ if len (agentPods ) == 0 {
348+ return metav1.Condition {
349+ Type : kvmv1 .ConditionTypeAgentPodsEvicted ,
350+ Status : metav1 .ConditionTrue ,
351+ Reason : "NoAgentPods" ,
352+ Message : "No agent pods are running on this node" ,
353+ }, nil
354+ }
355+
356+ sort .Strings (agentPods )
357+ return metav1.Condition {
358+ Type : kvmv1 .ConditionTypeAgentPodsEvicted ,
359+ Status : metav1 .ConditionFalse ,
360+ Reason : "AgentPodsRunning" ,
361+ Message : fmt .Sprintf ("%d agent pod(s) still running on node: %s" , len (agentPods ), strings .Join (agentPods , ", " )),
362+ }, nil
363+ }
364+
365+ // podToleratesTaint reports whether the pod tolerates the taint indefinitely.
366+ // Tolerations with a finite TolerationSeconds are excluded: the pod will
367+ // eventually be evicted and must not be treated as safe to ignore.
368+ func podToleratesTaint (log logr.Logger , pod * corev1.Pod , taint * corev1.Taint ) bool {
369+ for i := range pod .Spec .Tolerations {
370+ t := & pod .Spec .Tolerations [i ]
371+ if t .TolerationSeconds != nil {
372+ continue
373+ }
374+ if t .ToleratesTaint (log , taint , false ) {
375+ return true
376+ }
377+ }
378+ return false
379+ }
0 commit comments