Skip to content

Commit ee582af

Browse files
alexeymoskalev-devopshisco
authored andcommitted
fix(drain-plan): follow the Eviction API's budget rules; review follow-ups
Budget evaluation now reproduces the API server's eviction handler: Pending and terminating pods bypass PodDisruptionBudgets; a pod covered by more than one budget is reported as may-block (the eviction subresource refuses it); an unready pod is evictable under unhealthyPodEvictionPolicy AlwaysAllow, or under IfHealthyBudget when currentHealthy >= desiredHealthy; a budget whose status is not reconciled yet (observedGeneration < generation) is reported as may-block, because the API server answers 429 until it is. PlanNodeDrain lists budgets only for namespaces holding a pod whose decision depends on them, so a forbidden namespace with nothing but skipped pods no longer marks the plan as unevaluated; pdbChecked is set only when a decision reached the budget check. Frontend: the drain stays disabled after a plan request error; the plan request goes through apiUrl(); the plan mutation carries the toast metadata the app's mutation handler expects; the emptyDir acknowledgement uses the themed AlertBanner instead of a hardcoded colour. Comment and log-format nits from review.
1 parent fc87a49 commit ee582af

6 files changed

Lines changed: 216 additions & 60 deletions

File tree

‎internal/server/node_handlers.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func (s *Server) handleUncordonNode(w http.ResponseWriter, r *http.Request) {
9797

9898
// drainOptionsFromRequest turns the JSON body shared by the drain and drain-plan
9999
// endpoints into DrainOptions. The two endpoints differ only in what an omitted
100-
// deleteEmptyDirData means: the drain keeps its historical default (true, matching
100+
// deleteEmptyDirData means: the drain defaults to true (matching
101101
// kubectl drain --delete-emptydir-data), the plan defaults to false so a preview
102102
// never silently includes emptyDir data. The plan echoes the options it used.
103103
func drainOptionsFromRequest(req DrainRequest, deleteEmptyDirDefault bool) k8s.DrainOptions {
@@ -170,7 +170,7 @@ func (s *Server) writeDrainPlan(w http.ResponseWriter, r *http.Request, client k
170170
s.writeError(w, http.StatusForbidden, err.Error())
171171
return
172172
}
173-
log.Printf("[node-ops] Failed to plan drain for node %s: %v", nodeName, err)
173+
log.Printf("[node-ops] Failed to plan drain for node/%s: %v", nodeName, err)
174174
s.writeError(w, http.StatusInternalServerError, err.Error())
175175
return
176176
}

‎packages/k8s-ui/src/components/shared/DrainPlanDialog.test.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ describe('canConfirmDrain', () => {
4949
expect(canConfirmDrain({ plan: plan([]), nodeName: 'other', options: off, loading: false, acknowledgedEmptyDir: false, planSupported: true })).toBe(false)
5050
})
5151

52+
it('keeps the drain disabled after a plan request error, even if an older plan is still around', () => {
53+
const p = plan([pod('web', 'evict')])
54+
expect(canConfirmDrain({ plan: p, nodeName: 'worker-1', options: off, loading: false, error: 'boom', acknowledgedEmptyDir: false, planSupported: true })).toBe(false)
55+
expect(canConfirmDrain({ plan: null, nodeName: 'worker-1', options: off, loading: false, error: 'boom', acknowledgedEmptyDir: false, planSupported: false })).toBe(true)
56+
})
57+
5258
it('enables the drain once a matching plan is shown and no emptyDir data is at risk', () => {
5359
expect(canConfirmDrain({ plan: plan([pod('web', 'evict')]), nodeName: 'worker-1', options: off, loading: false, acknowledgedEmptyDir: false, planSupported: true })).toBe(true)
5460
})

‎packages/k8s-ui/src/components/shared/DrainPlanDialog.tsx‎

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useEffect, useState } from 'react'
2-
import { clsx } from 'clsx'
32
import { Loader2 } from 'lucide-react'
43
import { ConfirmDialog } from '../ui/ConfirmDialog'
54
import { Badge, type BadgeSeverity } from '../ui/Badge'
@@ -59,6 +58,8 @@ export interface CanConfirmDrainInput {
5958
nodeName: string
6059
options: DrainDialogOptions
6160
loading: boolean
61+
/** the last plan request failed; with plan support the drain stays disabled until a plan is shown again */
62+
error?: string | null
6263
acknowledgedEmptyDir: boolean
6364
/** false when the host cannot compute plans (no onPlanDrain); the dialog then only gates on the acknowledgement */
6465
planSupported: boolean
@@ -70,8 +71,9 @@ export interface CanConfirmDrainInput {
7071
* requires an explicit acknowledgement: the plan is an estimate, and a pod that
7172
* starts using emptyDir between the estimate and the drain would still lose its data.
7273
*/
73-
export function canConfirmDrain({ plan, nodeName, options, loading, acknowledgedEmptyDir, planSupported }: CanConfirmDrainInput): boolean {
74+
export function canConfirmDrain({ plan, nodeName, options, loading, error, acknowledgedEmptyDir, planSupported }: CanConfirmDrainInput): boolean {
7475
if (loading) return false
76+
if (planSupported && error) return false
7577
const current = planMatches(plan, nodeName, options) ? plan : null
7678
if (planSupported && !current) return false
7779
if (options.deleteEmptyDirData && !acknowledgedEmptyDir) return false
@@ -191,27 +193,24 @@ export function DrainPlanContent({
191193
)}
192194

193195
{needsAck && (
194-
<label
195-
className={clsx(
196-
'flex items-start gap-2 cursor-pointer rounded p-2 border',
197-
'border-red-500/40 bg-red-500/10 text-theme-text-primary',
198-
)}
199-
>
200-
<input
201-
type="checkbox"
202-
checked={acknowledgedEmptyDir}
203-
onChange={(e) => onAcknowledgeEmptyDir(e.target.checked)}
204-
className="mt-0.5 rounded border-theme-border"
205-
/>
206-
<span>
207-
{current && atRisk.length > 0
208-
? `Discard the emptyDir data of ${pluralize(atRisk.length, 'pod')}: ${atRisk.map((p) => `${p.namespace}/${p.name}`).join(', ')}.`
209-
: current
210-
? 'No pod on this node uses emptyDir right now; any that does when the drain runs will lose that data.'
211-
: 'Discard the emptyDir data of every evicted pod that uses emptyDir volumes.'}{' '}
212-
I understand this data cannot be recovered.
213-
</span>
214-
</label>
196+
<AlertBanner variant="error" title="emptyDir data will be discarded">
197+
<label className="flex items-start gap-2 cursor-pointer">
198+
<input
199+
type="checkbox"
200+
checked={acknowledgedEmptyDir}
201+
onChange={(e) => onAcknowledgeEmptyDir(e.target.checked)}
202+
className="mt-0.5 rounded border-theme-border"
203+
/>
204+
<span>
205+
{current && atRisk.length > 0
206+
? `Discard the emptyDir data of ${pluralize(atRisk.length, 'pod')}: ${atRisk.map((p) => `${p.namespace}/${p.name}`).join(', ')}.`
207+
: current
208+
? 'No pod on this node uses emptyDir right now; any that does when the drain runs will lose that data.'
209+
: 'Discard the emptyDir data of every evicted pod that uses emptyDir volumes.'}{' '}
210+
I understand this data cannot be recovered.
211+
</span>
212+
</label>
213+
</AlertBanner>
215214
)}
216215
</div>
217216
)
@@ -243,7 +242,7 @@ export function DrainPlanDialog({
243242
setAcknowledgedEmptyDir(false)
244243
}, [options.force, options.deleteEmptyDirData, open, plan?.generatedAt])
245244

246-
const confirmEnabled = canConfirmDrain({ plan, nodeName, options, loading, acknowledgedEmptyDir, planSupported })
245+
const confirmEnabled = canConfirmDrain({ plan, nodeName, options, loading, error, acknowledgedEmptyDir, planSupported })
247246

248247
return (
249248
<ConfirmDialog

‎pkg/k8score/drain_plan.go‎

Lines changed: 109 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"sort"
7+
"strings"
78
"time"
89

910
corev1 "k8s.io/api/core/v1"
@@ -17,13 +18,13 @@ import (
1718
type DrainOutcome string
1819

1920
const (
20-
// DrainOutcomeEvict: the pod would be evicted.
2121
DrainOutcomeEvict DrainOutcome = "evict"
2222
// DrainOutcomeSkip: the pod would be left alone (DaemonSet, mirror, unmanaged, emptyDir, terminal).
2323
DrainOutcomeSkip DrainOutcome = "skip"
24-
// DrainOutcomeMayBlock: the pod would be evicted, but a PodDisruptionBudget currently allows no
25-
// disruptions, so the eviction may be refused until the budget recovers. Evidence, not a verdict:
26-
// only the Eviction API and live state decide.
24+
// DrainOutcomeMayBlock: the pod would be evicted, but its PodDisruptionBudget would make the
25+
// Eviction API refuse it right now (no disruptions allowed, status not yet reconciled, or more
26+
// than one budget covering the pod). Evidence, not a verdict: only the Eviction API and live
27+
// state decide.
2728
DrainOutcomeMayBlock DrainOutcome = "may-block"
2829
)
2930

@@ -35,22 +36,22 @@ type PodDrainDecision struct {
3536
Reason string `json:"reason"`
3637
EmptyDir bool `json:"emptyDir"` // the pod uses emptyDir volumes; evicting it discards that data
3738
PDB string `json:"pdb,omitempty"` // namespace/name of the PDB behind a may-block outcome
38-
// PDBChecked is true when the PodDisruptionBudgets of the pod's namespace were available to
39-
// this decision (a skip decided earlier in the filter chain never needs them). It is false
40-
// during drain execution (the Eviction API decides) and when they could not be listed for the
41-
// pod's namespace (see DrainPlan.PDBError).
39+
// PDBChecked is true when PodDisruptionBudgets were consulted for this decision. It is false
40+
// for decisions that never reach the budget check (skips, Pending or terminating pods), during
41+
// drain execution (the Eviction API decides), and when the budgets of the pod's namespace could
42+
// not be listed (see DrainPlan.PDBError).
4243
PDBChecked bool `json:"pdbChecked"`
4344
}
4445

4546
// ClassifyPodForDrain decides, without touching the cluster, what DrainNode would do with pod
4647
// under opts. The rules mirror kubectl drain's filters: terminal and mirror pods are skipped,
4748
// DaemonSet pods are never evicted (kubectl refuses the drain without --ignore-daemonsets;
4849
// DrainNode always ignores them), pods without a controller owner need Force, pods using
49-
// emptyDir need DeleteEmptyDirData. A pod that would be evicted but is covered by a PDB with
50-
// no disruptions allowed is reported as may-block. A pod that is already terminating is
51-
// reported as evict without consulting PDBs: the Eviction API admits it regardless of budgets.
50+
// emptyDir need DeleteEmptyDirData. Budget evaluation follows the Eviction API: pods that are
51+
// Pending or already terminating bypass PodDisruptionBudgets entirely; otherwise see
52+
// pdbBlocking for the cases reported as may-block.
5253
func ClassifyPodForDrain(pod corev1.Pod, opts DrainOptions, pdbs []policyv1.PodDisruptionBudget) PodDrainDecision {
53-
d := PodDrainDecision{Namespace: pod.Namespace, Name: pod.Name, PDBChecked: pdbs != nil, EmptyDir: hasLocalStorage(pod)}
54+
d := PodDrainDecision{Namespace: pod.Namespace, Name: pod.Name, EmptyDir: hasLocalStorage(pod)}
5455
skip := func(reason string) PodDrainDecision {
5556
d.Outcome, d.Reason = DrainOutcomeSkip, reason
5657
return d
@@ -76,16 +77,21 @@ func ClassifyPodForDrain(pod corev1.Pod, opts DrainOptions, pdbs []policyv1.PodD
7677
return skip("uses emptyDir volumes; their data is lost on eviction, enable deleteEmptyDirData to evict")
7778
}
7879

79-
if pod.DeletionTimestamp != nil {
80+
if bypassesPDB(pod) {
8081
d.Outcome = DrainOutcomeEvict
81-
d.Reason = "already terminating; the eviction is admitted regardless of PodDisruptionBudgets"
82+
if pod.DeletionTimestamp != nil {
83+
d.Reason = "already terminating; the eviction is admitted regardless of PodDisruptionBudgets"
84+
} else {
85+
d.Reason = "still pending; the eviction is admitted regardless of PodDisruptionBudgets"
86+
}
8287
return d
8388
}
8489

85-
if name, blocked := pdbBlocking(pod, pdbs); blocked {
90+
d.PDBChecked = pdbs != nil
91+
if name, reason, blocked := pdbBlocking(pod, pdbs); blocked {
8692
d.Outcome = DrainOutcomeMayBlock
8793
d.PDB = name
88-
d.Reason = fmt.Sprintf("PodDisruptionBudget %s currently allows no disruptions; the eviction may be refused until the budget recovers", name)
94+
d.Reason = reason
8995
return d
9096
}
9197

@@ -99,23 +105,83 @@ func ClassifyPodForDrain(pod corev1.Pod, opts DrainOptions, pdbs []policyv1.PodD
99105
return d
100106
}
101107

102-
// pdbBlocking returns the first PDB in the pod's namespace that selects the pod and has
103-
// no disruptions allowed. A nil selector matches nothing, an empty selector matches everything,
104-
// matching the policy/v1 semantics.
105-
func pdbBlocking(pod corev1.Pod, pdbs []policyv1.PodDisruptionBudget) (string, bool) {
108+
// bypassesPDB reports whether the Eviction API ignores PodDisruptionBudgets for the pod
109+
// (Succeeded, Failed or Pending phase, or a deletion already in progress), as in
110+
// canIgnorePDB of the API server's eviction handler.
111+
func bypassesPDB(pod corev1.Pod) bool {
112+
return pod.DeletionTimestamp != nil ||
113+
pod.Status.Phase == corev1.PodSucceeded ||
114+
pod.Status.Phase == corev1.PodFailed ||
115+
pod.Status.Phase == corev1.PodPending
116+
}
117+
118+
// isPodReady mirrors k8s.io/kubernetes/pkg/api/v1/pod.IsPodReady: the Ready condition is True.
119+
func isPodReady(pod corev1.Pod) bool {
120+
for _, c := range pod.Status.Conditions {
121+
if c.Type == corev1.PodReady {
122+
return c.Status == corev1.ConditionTrue
123+
}
124+
}
125+
return false
126+
}
127+
128+
// matchingPDBs returns the PDBs in the pod's namespace whose selector selects the pod.
129+
// A nil selector matches nothing, an empty selector matches everything (policy/v1 semantics).
130+
func matchingPDBs(pod corev1.Pod, pdbs []policyv1.PodDisruptionBudget) []*policyv1.PodDisruptionBudget {
106131
podLabels := labels.Set(pod.Labels)
132+
var out []*policyv1.PodDisruptionBudget
107133
for i := range pdbs {
108134
p := &pdbs[i]
109-
if p.Namespace != pod.Namespace || p.Spec.Selector == nil || p.Status.DisruptionsAllowed > 0 {
135+
if p.Namespace != pod.Namespace || p.Spec.Selector == nil {
110136
continue
111137
}
112138
selector, err := metav1.LabelSelectorAsSelector(p.Spec.Selector)
113139
if err != nil || !selector.Matches(podLabels) {
114140
continue
115141
}
116-
return p.Namespace + "/" + p.Name, true
142+
out = append(out, p)
117143
}
118-
return "", false
144+
return out
145+
}
146+
147+
// pdbBlocking reproduces the decisions of the API server's eviction handler on the given
148+
// snapshot and returns the budget name and an operator-facing reason when the eviction would
149+
// be refused right now:
150+
// - more than one budget selects the pod: the eviction subresource refuses such pods;
151+
// - the pod is not Ready: allowed under unhealthyPodEvictionPolicy AlwaysAllow, or under the
152+
// default IfHealthyBudget when currentHealthy >= desiredHealthy > 0; otherwise the budget applies;
153+
// - the budget's status is not reconciled yet (observedGeneration < generation): refused (429);
154+
// - disruptionsAllowed is 0: refused (429).
155+
func pdbBlocking(pod corev1.Pod, pdbs []policyv1.PodDisruptionBudget) (name, reason string, blocked bool) {
156+
matched := matchingPDBs(pod, pdbs)
157+
if len(matched) == 0 {
158+
return "", "", false
159+
}
160+
if len(matched) > 1 {
161+
names := make([]string, 0, len(matched))
162+
for _, p := range matched {
163+
names = append(names, p.Namespace+"/"+p.Name)
164+
}
165+
return names[0], fmt.Sprintf("covered by %d PodDisruptionBudgets (%s); the Eviction API refuses pods covered by more than one budget", len(matched), strings.Join(names, ", ")), true
166+
}
167+
p := matched[0]
168+
name = p.Namespace + "/" + p.Name
169+
170+
if !isPodReady(pod) {
171+
if p.Spec.UnhealthyPodEvictionPolicy != nil && *p.Spec.UnhealthyPodEvictionPolicy == policyv1.AlwaysAllow {
172+
return "", "", false
173+
}
174+
if p.Status.CurrentHealthy >= p.Status.DesiredHealthy && p.Status.DesiredHealthy > 0 {
175+
return "", "", false
176+
}
177+
}
178+
if p.Status.ObservedGeneration < p.Generation {
179+
return name, fmt.Sprintf("PodDisruptionBudget %s has not been reconciled yet (observedGeneration %d < generation %d); evictions are refused until its status is up to date", name, p.Status.ObservedGeneration, p.Generation), true
180+
}
181+
if p.Status.DisruptionsAllowed <= 0 {
182+
return name, fmt.Sprintf("PodDisruptionBudget %s currently allows no disruptions; the eviction may be refused until the budget recovers", name), true
183+
}
184+
return "", "", false
119185
}
120186

121187
// DrainPlanOptions echoes the options a plan was evaluated with, so the caller never has to
@@ -150,8 +216,9 @@ type DrainPlan struct {
150216

151217
// PlanNodeDrain lists the pods on nodeName and classifies each one with ClassifyPodForDrain.
152218
// It performs only reads with the given client, so it runs under the caller's RBAC identity and
153-
// never cordons or evicts. PDBs are listed per namespace; a namespace whose PDBs cannot be read
154-
// is classified without PDB knowledge and reported as such instead of pretending no PDB exists.
219+
// never cordons or evicts. PDBs are listed per namespace and only for namespaces holding pods
220+
// whose decision depends on them; a namespace whose PDBs cannot be read is classified without
221+
// PDB knowledge and reported as such instead of pretending no PDB exists.
155222
func PlanNodeDrain(ctx context.Context, client kubernetes.Interface, nodeName string, opts DrainOptions) (*DrainPlan, error) {
156223
if _, err := client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}); err != nil {
157224
return nil, err
@@ -177,10 +244,21 @@ func PlanNodeDrain(ctx context.Context, client kubernetes.Interface, nodeName st
177244
PDBsEvaluated: true,
178245
}
179246

247+
// First pass without budgets: skip decisions and PDB bypasses are final and never need a
248+
// PDB, so budgets are listed only for namespaces where a pod could reach the budget check.
249+
firstPass := make([]PodDrainDecision, len(podList.Items))
250+
needsPDB := map[string]bool{}
251+
for i, pod := range podList.Items {
252+
firstPass[i] = ClassifyPodForDrain(pod, opts, nil)
253+
if firstPass[i].Outcome != DrainOutcomeSkip && !bypassesPDB(pod) {
254+
needsPDB[pod.Namespace] = true
255+
}
256+
}
257+
180258
// PDBs per namespace: nil marks "could not list", an empty slice marks "listed, none".
181259
pdbsByNamespace := map[string][]policyv1.PodDisruptionBudget{}
182260
for _, pod := range podList.Items {
183-
if _, seen := pdbsByNamespace[pod.Namespace]; seen {
261+
if _, seen := pdbsByNamespace[pod.Namespace]; seen || !needsPDB[pod.Namespace] {
184262
continue
185263
}
186264
list, err := client.PolicyV1().PodDisruptionBudgets(pod.Namespace).List(ctx, metav1.ListOptions{})
@@ -199,8 +277,11 @@ func PlanNodeDrain(ctx context.Context, client kubernetes.Interface, nodeName st
199277
pdbsByNamespace[pod.Namespace] = pdbs
200278
}
201279

202-
for _, pod := range podList.Items {
203-
d := ClassifyPodForDrain(pod, opts, pdbsByNamespace[pod.Namespace])
280+
for i, pod := range podList.Items {
281+
d := firstPass[i]
282+
if needsPDB[pod.Namespace] && pdbsByNamespace[pod.Namespace] != nil {
283+
d = ClassifyPodForDrain(pod, opts, pdbsByNamespace[pod.Namespace])
284+
}
204285
plan.Pods = append(plan.Pods, d)
205286
switch d.Outcome {
206287
case DrainOutcomeEvict:

0 commit comments

Comments
 (0)