Ctrl K
ComunidadGuías…

pkg/k8score/drain_plan.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"context"
	"fmt"
	"sort"
	"strings"
	"time"

	corev1 "k8s.io/api/core/v1"
	policyv1 "k8s.io/api/policy/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/client-go/kubernetes"
)

// DrainOutcome is what a drain is expected to do with one pod.
type DrainOutcome string

const (
	DrainOutcomeEvict DrainOutcome = "evict"
	// DrainOutcomeSkip: the pod would be left alone (DaemonSet, mirror, unmanaged, emptyDir, terminal).
	DrainOutcomeSkip DrainOutcome = "skip"
	// DrainOutcomeMayBlock: the pod would be evicted, but its PodDisruptionBudget would make the
	// Eviction API refuse it right now (no disruptions allowed, status not yet reconciled, or more
	// than one budget covering the pod). Evidence, not a verdict: only the Eviction API and live
	// state decide.
	DrainOutcomeMayBlock DrainOutcome = "may-block"
)

// PodDrainDecision is the per-pod result of ClassifyPodForDrain.
type PodDrainDecision struct {
	Namespace string       `json:"namespace"`
	Name      string       `json:"name"`
	Outcome   DrainOutcome `json:"outcome"`
	Reason    string       `json:"reason"`
	EmptyDir  bool         `json:"emptyDir"`      // the pod uses emptyDir volumes; evicting it discards that data
	PDB       string       `json:"pdb,omitempty"` // namespace/name of the PDB behind a may-block outcome
	// PDBChecked is true when PodDisruptionBudgets were consulted for this decision. It is false
	// for decisions that never reach the budget check (skips, Pending or terminating pods), during
	// drain execution (the Eviction API decides), and when the budgets of the pod's namespace could
	// not be listed (see DrainPlan.PDBError).
	PDBChecked  bool `json:"pdbChecked"`
	Terminating bool `json:"terminating"`
	// ExpectedSkip distinguishes normal drain exclusions from protected workloads.
	ExpectedSkip bool `json:"expectedSkip"`
}

func (d PodDrainDecision) BlocksDrain() bool {
	return d.Outcome == DrainOutcomeSkip && !d.ExpectedSkip
}

// ClassifyPodForDrain decides, without touching the cluster, what DrainNode would do with pod
// under opts. The rules mirror kubectl drain's filters: terminal and mirror pods are skipped,
// DaemonSet pods are never evicted (kubectl refuses the drain without --ignore-daemonsets;
// DrainNode always ignores them), pods without a controller owner need Force, pods using
// emptyDir need DeleteEmptyDirData. Budget evaluation follows the Eviction API: pods that are
// Pending or already terminating bypass PodDisruptionBudgets entirely; otherwise see
// pdbBlocking for the cases reported as may-block.
func ClassifyPodForDrain(pod corev1.Pod, opts DrainOptions, pdbs []policyv1.PodDisruptionBudget) PodDrainDecision {
	d := PodDrainDecision{Namespace: pod.Namespace, Name: pod.Name, EmptyDir: hasLocalStorage(pod), Terminating: pod.DeletionTimestamp != nil}
	skip := func(reason string) PodDrainDecision {
		d.Outcome, d.Reason = DrainOutcomeSkip, reason
		return d
	}

	if pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
		d.ExpectedSkip = true
		return skip(fmt.Sprintf("pod is %s; nothing to evict", string(pod.Status.Phase)))
	}
	if _, isMirror := pod.Annotations[corev1.MirrorPodAnnotationKey]; isMirror {
		d.ExpectedSkip = true
		return skip("static (mirror) pod managed by the kubelet; cannot be evicted")
	}
	controller := metav1.GetControllerOf(&pod)
	if controller != nil && controller.Kind == "DaemonSet" {
		d.ExpectedSkip = true
		if opts.IgnoreDaemonSets {
			return skip("managed by a DaemonSet; the DaemonSet controller would recreate it on this node")
		}
		return skip("managed by a DaemonSet; DaemonSet pods are never evicted by a drain (kubectl refuses without --ignore-daemonsets)")
	}
	if controller == nil && !opts.Force {
		return skip("not managed by a controller; would be lost, enable force to evict anyway")
	}
	if !opts.DeleteEmptyDirData && d.EmptyDir {
		return skip("uses emptyDir volumes; their data is lost on eviction, enable the Delete emptyDir data option to evict")
	}

	if bypassesPDB(pod) {
		d.Outcome = DrainOutcomeEvict
		if pod.DeletionTimestamp != nil {
			d.Reason = "already terminating; the eviction is admitted regardless of PodDisruptionBudgets"
		} else {
			d.Reason = "still pending; the eviction is admitted regardless of PodDisruptionBudgets"
		}
		return d
	}

	d.PDBChecked = pdbs != nil
	if name, reason, blocked := pdbBlocking(pod, pdbs); blocked {
		d.Outcome = DrainOutcomeMayBlock
		d.PDB = name
		d.Reason = reason
		return d
	}

	d.Outcome = DrainOutcomeEvict
	switch {
	case controller == nil:
		d.Reason = "not managed by a controller; evicted because force is set"
	default:
		d.Reason = fmt.Sprintf("managed by %s %s; the controller reschedules it", controller.Kind, controller.Name)
	}
	return d
}

// bypassesPDB reports whether the Eviction API ignores PodDisruptionBudgets for the pod
// (Succeeded, Failed or Pending phase, or a deletion already in progress), as in
// canIgnorePDB of the API server's eviction handler.
func bypassesPDB(pod corev1.Pod) bool {
	return pod.DeletionTimestamp != nil ||
		pod.Status.Phase == corev1.PodSucceeded ||
		pod.Status.Phase == corev1.PodFailed ||
		pod.Status.Phase == corev1.PodPending
}

// isPodReady mirrors k8s.io/kubernetes/pkg/api/v1/pod.IsPodReady: the Ready condition is True.
func isPodReady(pod corev1.Pod) bool {
	for _, c := range pod.Status.Conditions {
		if c.Type == corev1.PodReady {
			return c.Status == corev1.ConditionTrue
		}
	}
	return false
}

// matchingPDBs returns the PDBs in the pod's namespace whose selector selects the pod.
// A nil selector matches nothing, an empty selector matches everything (policy/v1 semantics).
func matchingPDBs(pod corev1.Pod, pdbs []policyv1.PodDisruptionBudget) []*policyv1.PodDisruptionBudget {
	podLabels := labels.Set(pod.Labels)
	var out []*policyv1.PodDisruptionBudget
	for i := range pdbs {
		p := &pdbs[i]
		if p.Namespace != pod.Namespace || p.Spec.Selector == nil {
			continue
		}
		selector, err := metav1.LabelSelectorAsSelector(p.Spec.Selector)
		if err != nil || !selector.Matches(podLabels) {
			continue
		}
		out = append(out, p)
	}
	return out
}

// pdbBlocking reproduces the decisions of the API server's eviction handler on the given
// snapshot and returns the budget name and an operator-facing reason when the eviction would
// be refused right now:
//   - more than one budget selects the pod: the eviction subresource refuses such pods;
//   - the pod is not Ready: allowed under unhealthyPodEvictionPolicy AlwaysAllow, or under the
//     default IfHealthyBudget when currentHealthy >= desiredHealthy > 0; otherwise the budget applies;
//   - the budget's status is not reconciled yet (observedGeneration < generation): refused (429);
//   - disruptionsAllowed is 0: refused (429).
func pdbBlocking(pod corev1.Pod, pdbs []policyv1.PodDisruptionBudget) (name, reason string, blocked bool) {
	matched := matchingPDBs(pod, pdbs)
	if len(matched) == 0 {
		return "", "", false
	}
	if len(matched) > 1 {
		names := make([]string, 0, len(matched))
		for _, p := range matched {
			names = append(names, p.Namespace+"/"+p.Name)
		}
		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
	}
	p := matched[0]
	name = p.Namespace + "/" + p.Name

	if !isPodReady(pod) {
		if p.Spec.UnhealthyPodEvictionPolicy != nil && *p.Spec.UnhealthyPodEvictionPolicy == policyv1.AlwaysAllow {
			return "", "", false
		}
		if p.Status.CurrentHealthy >= p.Status.DesiredHealthy && p.Status.DesiredHealthy > 0 {
			return "", "", false
		}
	}
	if p.Status.ObservedGeneration < p.Generation {
		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
	}
	if p.Status.DisruptionsAllowed <= 0 {
		return name, fmt.Sprintf("PodDisruptionBudget %s currently allows no disruptions; the eviction may be refused until the budget recovers", name), true
	}
	return "", "", false
}

// DrainPlanOptions echoes the options a plan was evaluated with, so the caller never has to
// guess which defaults applied.
type DrainPlanOptions struct {
	IgnoreDaemonSets   bool   `json:"ignoreDaemonSets"`
	DeleteEmptyDirData bool   `json:"deleteEmptyDirData"`
	Force              bool   `json:"force"`
	GracePeriodSeconds *int64 `json:"gracePeriodSeconds,omitempty"`
}

// DrainPlanSummary counts pods per outcome.
type DrainPlanSummary struct {
	Evict    int `json:"evict"`
	Skip     int `json:"skip"`
	MayBlock int `json:"mayBlock"`
}

// DrainPlan is a read-only estimate of what draining a node would do. It is computed from a
// snapshot: pods, budgets and permissions can change before the drain runs, which re-lists and
// re-evaluates live state.
type DrainPlan struct {
	Node          string             `json:"node"`
	GeneratedAt   time.Time          `json:"generatedAt"`
	Estimate      bool               `json:"estimate"` // always true; execution re-evaluates
	Options       DrainPlanOptions   `json:"options"`
	Summary       DrainPlanSummary   `json:"summary"`
	Pods          []PodDrainDecision `json:"pods"`
	PDBsEvaluated bool               `json:"pdbsEvaluated"`      // false when at least one namespace\'s PDBs could not be listed (per pod: pdbChecked)
	PDBError      string             `json:"pdbError,omitempty"` // why, when PDBsEvaluated is false
}

// PlanNodeDrain lists the pods on nodeName and classifies each one with ClassifyPodForDrain.
// It performs only reads with the given client, so it runs under the caller's RBAC identity and
// never cordons or evicts. PDBs are listed per namespace and only for namespaces holding pods
// whose decision depends on them; a namespace whose PDBs cannot be read is classified without
// PDB knowledge and reported as such instead of pretending no PDB exists.
func PlanNodeDrain(ctx context.Context, client kubernetes.Interface, nodeName string, opts DrainOptions) (*DrainPlan, error) {
	if _, err := client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}); err != nil {
		return nil, err
	}
	podList, err := client.CoreV1().Pods("").List(ctx, metav1.ListOptions{
		FieldSelector: "spec.nodeName=" + nodeName,
	})
	if err != nil {
		return nil, fmt.Errorf("list pods on node: %w", err)
	}

	plan := &DrainPlan{
		Node:        nodeName,
		GeneratedAt: time.Now().UTC(),
		Estimate:    true,
		Options: DrainPlanOptions{
			IgnoreDaemonSets:   opts.IgnoreDaemonSets,
			DeleteEmptyDirData: opts.DeleteEmptyDirData,
			Force:              opts.Force,
			GracePeriodSeconds: opts.GracePeriodSeconds,
		},
		Pods:          make([]PodDrainDecision, 0, len(podList.Items)),
		PDBsEvaluated: true,
	}

	// First pass without budgets: skip decisions and PDB bypasses are final and never need a
	// PDB, so budgets are listed only for namespaces where a pod could reach the budget check.
	firstPass := make([]PodDrainDecision, len(podList.Items))
	needsPDB := map[string]bool{}
	for i, pod := range podList.Items {
		firstPass[i] = ClassifyPodForDrain(pod, opts, nil)
		if firstPass[i].Outcome != DrainOutcomeSkip && !bypassesPDB(pod) {
			needsPDB[pod.Namespace] = true
		}
	}

	// PDBs per namespace: nil marks "could not list", an empty slice marks "listed, none".
	pdbsByNamespace := map[string][]policyv1.PodDisruptionBudget{}
	for _, pod := range podList.Items {
		if _, seen := pdbsByNamespace[pod.Namespace]; seen || !needsPDB[pod.Namespace] {
			continue
		}
		list, err := client.PolicyV1().PodDisruptionBudgets(pod.Namespace).List(ctx, metav1.ListOptions{})
		if err != nil {
			pdbsByNamespace[pod.Namespace] = nil
			plan.PDBsEvaluated = false
			if plan.PDBError == "" {
				plan.PDBError = fmt.Sprintf("list PodDisruptionBudgets in %s: %v", pod.Namespace, err)
			}
			continue
		}
		pdbs := list.Items
		if pdbs == nil {
			pdbs = []policyv1.PodDisruptionBudget{}
		}
		pdbsByNamespace[pod.Namespace] = pdbs
	}

	for i, pod := range podList.Items {
		d := firstPass[i]
		if needsPDB[pod.Namespace] && pdbsByNamespace[pod.Namespace] != nil {
			d = ClassifyPodForDrain(pod, opts, pdbsByNamespace[pod.Namespace])
		}
		plan.Pods = append(plan.Pods, d)
		switch d.Outcome {
		case DrainOutcomeEvict:
			plan.Summary.Evict++
		case DrainOutcomeSkip:
			plan.Summary.Skip++
		case DrainOutcomeMayBlock:
			plan.Summary.MayBlock++
		}
	}
	sort.Slice(plan.Pods, func(i, j int) bool {
		if plan.Pods[i].Namespace != plan.Pods[j].Namespace {
			return plan.Pods[i].Namespace < plan.Pods[j].Namespace
		}
		return plan.Pods[i].Name < plan.Pods[j].Name
	})
	return plan, nil
}