@@ -2,9 +2,41 @@ package evict
22
33import (
44 "context"
5+ "errors"
6+ "fmt"
7+ "log"
8+ "reflect"
9+ "slices"
10+ "strings"
511
12+ "github.com/samber/lo"
13+ appsv1 "k8s.io/api/apps/v1"
14+ corev1 "k8s.io/api/core/v1"
15+ policyv1 "k8s.io/api/policy/v1"
16+ apierrors "k8s.io/apimachinery/pkg/api/errors"
17+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
18+ "k8s.io/apimachinery/pkg/runtime"
19+ "k8s.io/apimachinery/pkg/util/intstr"
20+ "k8s.io/apimachinery/pkg/util/validation"
621 "k8s.io/client-go/kubernetes"
22+ "k8s.io/client-go/tools/pager"
723 "sigs.k8s.io/controller-runtime/pkg/client"
24+
25+ commonk8s "github.com/DataDog/datadog-operator/cmd/kubectl-datadog/autoscaling/cluster/common/k8s"
26+ )
27+
28+ // Label keys used to mark PodDisruptionBudgets created by this command. Both
29+ // must be present for cleanup to consider the PDB ours — this makes the
30+ // cleanup safe against accidentally removing a user PDB with a colliding name.
31+ const (
32+ pdbManagedByLabelKey = "app.kubernetes.io/managed-by"
33+ pdbManagedByLabelValue = "kubectl-datadog"
34+ pdbTempLabelKey = "autoscaling.datadoghq.com/temporary-pdb"
35+ pdbTempLabelValue = "true"
36+ // pdbNameSuffix is appended to the controller name to form the temp PDB
37+ // name. Kept short so that long controller names stay under the 63-char
38+ // DNS label limit after truncation.
39+ pdbNameSuffix = "-evict-legacy"
840)
941
1042// ensureTempPDBs scans the pods running on the nodes of every target and
@@ -17,13 +49,307 @@ import (
1749// created them. ensureTempPDBs itself is idempotent: a PDB created by a
1850// previous (possibly crashed) run is detected by its labels and left alone.
1951func ensureTempPDBs (ctx context.Context , clientset kubernetes.Interface , ctrlClient client.Client , targets []Target , dryRun bool ) error {
20- panic ("TODO: ensureTempPDBs — implemented in PR #6" )
52+ // Targets across all manager types are merged: the orchestrator blocks
53+ // on waitEKSNodegroupEmpty before cleaning up the temporary PDBs, so EKS
54+ // MNG nodes observe the PDBs during their drain too — without that, EKS
55+ // could disrupt every replica of an otherwise unprotected workload at
56+ // once when all replicas happen to live on the same node group.
57+ allNodes := lo .FlatMap (targets , func (t Target , _ int ) []string { return t .Nodes })
58+ nodeSet := lo .SliceToMap (allNodes , func (n string ) (string , struct {}) { return n , struct {}{} })
59+ if len (nodeSet ) == 0 {
60+ return nil
61+ }
62+
63+ controllers , err := discoverControllers (ctx , clientset , nodeSet )
64+ if err != nil {
65+ return fmt .Errorf ("failed to discover controllers: %w" , err )
66+ }
67+ if len (controllers ) == 0 {
68+ return nil
69+ }
70+
71+ // Group controllers by namespace to amortize the per-namespace PDB list.
72+ byNamespace := make (map [string ][]controllerInfo )
73+ for _ , c := range controllers {
74+ byNamespace [c .Namespace ] = append (byNamespace [c .Namespace ], c )
75+ }
76+
77+ var errs []error
78+ for ns , ctrls := range byNamespace {
79+ existing , err := listNamespacePDBs (ctx , clientset , ns )
80+ if err != nil {
81+ errs = append (errs , fmt .Errorf ("namespace %s: failed to list PDBs: %w" , ns , err ))
82+ continue
83+ }
84+ for _ , c := range ctrls {
85+ if hasUserPDB (existing , c .Selector ) {
86+ continue
87+ }
88+ if err := createTempPDB (ctx , ctrlClient , c , dryRun ); err != nil {
89+ errs = append (errs , fmt .Errorf ("controller %s/%s/%s: %w" , c .Namespace , c .Kind , c .Name , err ))
90+ }
91+ }
92+ }
93+ return errors .Join (errs ... )
2194}
2295
2396// cleanupTempPDBs deletes every PodDisruptionBudget cluster-wide that carries
2497// both temp-PDB labels. Listing by labels (not by a state struct returned from
2598// ensureTempPDBs) is what makes the command crash-safe: a re-run after a kill
2699// still finds and removes the orphans left by the previous attempt.
27100func cleanupTempPDBs (ctx context.Context , ctrlClient client.Client , dryRun bool ) error {
28- panic ("TODO: cleanupTempPDBs — implemented in PR #6" )
101+ list := & policyv1.PodDisruptionBudgetList {}
102+ if err := ctrlClient .List (ctx , list , client.MatchingLabels {
103+ pdbManagedByLabelKey : pdbManagedByLabelValue ,
104+ pdbTempLabelKey : pdbTempLabelValue ,
105+ }); err != nil {
106+ return fmt .Errorf ("failed to list temporary PDBs: %w" , err )
107+ }
108+ if len (list .Items ) == 0 {
109+ return nil
110+ }
111+ var errs []error
112+ for i := range list .Items {
113+ pdb := & list .Items [i ]
114+ if dryRun {
115+ log .Printf ("[dry-run] would delete PDB %s/%s" , pdb .Namespace , pdb .Name )
116+ continue
117+ }
118+ if err := commonk8s .Delete (ctx , ctrlClient , pdb ); err != nil {
119+ errs = append (errs , fmt .Errorf ("PDB %s/%s: %w" , pdb .Namespace , pdb .Name , err ))
120+ }
121+ }
122+ return errors .Join (errs ... )
123+ }
124+
125+ // controllerKey identifies a top-level controller that owns evictable pods on
126+ // our target nodes. It is the dedup key in the seen map and the identity half
127+ // of controllerInfo.
128+ type controllerKey struct {
129+ Namespace string
130+ Kind string // "Deployment", "StatefulSet", "ReplicaSet"
131+ Name string
132+ }
133+
134+ // controllerInfo is a controllerKey plus the controller's pod selector — what a
135+ // PDB would match on.
136+ type controllerInfo struct {
137+ controllerKey
138+ Selector * metav1.LabelSelector
139+ }
140+
141+ // discoverControllers lists every Pod cluster-wide once (paginated) and, for
142+ // each Pod scheduled on one of the target nodes, resolves the top-level
143+ // controller. Listing once and filtering client-side avoids the N-API-calls
144+ // problem of doing one List per node, which on a large legacy fleet would
145+ // dominate the command's wall-clock time. The resulting slice contains each
146+ // controller at most once.
147+ func discoverControllers (ctx context.Context , clientset kubernetes.Interface , nodeSet map [string ]struct {}) ([]controllerInfo , error ) {
148+ seen := make (map [controllerKey ]* metav1.LabelSelector )
149+ depCache := make (map [client.ObjectKey ]* appsv1.Deployment )
150+ rsCache := make (map [client.ObjectKey ]* appsv1.ReplicaSet )
151+ stsCache := make (map [client.ObjectKey ]* appsv1.StatefulSet )
152+
153+ p := pager .New (func (ctx context.Context , opts metav1.ListOptions ) (runtime.Object , error ) {
154+ return clientset .CoreV1 ().Pods (metav1 .NamespaceAll ).List (ctx , opts )
155+ })
156+ if err := p .EachListItem (ctx , metav1.ListOptions {}, func (obj runtime.Object ) error {
157+ pod := obj .(* corev1.Pod )
158+ if _ , onTarget := nodeSet [pod .Spec .NodeName ]; ! onTarget {
159+ return nil
160+ }
161+ if shouldSkipEviction (pod ) {
162+ return nil
163+ }
164+ info , err := resolveTopLevelController (ctx , clientset , pod , depCache , rsCache , stsCache )
165+ if err != nil {
166+ log .Printf ("Warning: cannot resolve controller for pod %s/%s: %v" , pod .Namespace , pod .Name , err )
167+ return nil
168+ }
169+ if info == nil {
170+ return nil
171+ }
172+ seen [info .controllerKey ] = info .Selector
173+ return nil
174+ }); err != nil {
175+ return nil , fmt .Errorf ("list pods: %w" , err )
176+ }
177+
178+ return lo .MapToSlice (seen , func (key controllerKey , selector * metav1.LabelSelector ) controllerInfo {
179+ return controllerInfo {controllerKey : key , Selector : selector }
180+ }), nil
181+ }
182+
183+ // resolveTopLevelController walks a Pod's owner chain up to the workload
184+ // controller (Deployment > ReplicaSet > Pod, StatefulSet > Pod). Returns nil
185+ // for Pods whose top-level owner is not a stable workload — Jobs (TTL-managed),
186+ // DaemonSets (already skipped at eviction), or bare Pods.
187+ func resolveTopLevelController (
188+ ctx context.Context ,
189+ clientset kubernetes.Interface ,
190+ pod * corev1.Pod ,
191+ depCache map [client.ObjectKey ]* appsv1.Deployment ,
192+ rsCache map [client.ObjectKey ]* appsv1.ReplicaSet ,
193+ stsCache map [client.ObjectKey ]* appsv1.StatefulSet ,
194+ ) (* controllerInfo , error ) {
195+ owner := metav1 .GetControllerOf (pod )
196+ if owner == nil {
197+ return nil , nil
198+ }
199+ switch owner .Kind {
200+ case "ReplicaSet" :
201+ rs , err := getCached (ctx , rsCache , pod .Namespace , owner .Name , clientset .AppsV1 ().ReplicaSets (pod .Namespace ).Get )
202+ if err != nil {
203+ return nil , err
204+ }
205+ rsOwner := metav1 .GetControllerOf (rs )
206+ if rsOwner != nil && rsOwner .Kind == "Deployment" {
207+ dep , err := getCached (ctx , depCache , pod .Namespace , rsOwner .Name , clientset .AppsV1 ().Deployments (pod .Namespace ).Get )
208+ if err != nil {
209+ return nil , err
210+ }
211+ return & controllerInfo {
212+ controllerKey : controllerKey {Namespace : pod .Namespace , Kind : "Deployment" , Name : dep .Name },
213+ Selector : dep .Spec .Selector ,
214+ }, nil
215+ }
216+ return & controllerInfo {
217+ controllerKey : controllerKey {Namespace : pod .Namespace , Kind : "ReplicaSet" , Name : rs .Name },
218+ Selector : rs .Spec .Selector ,
219+ }, nil
220+ case "StatefulSet" :
221+ sts , err := getCached (ctx , stsCache , pod .Namespace , owner .Name , clientset .AppsV1 ().StatefulSets (pod .Namespace ).Get )
222+ if err != nil {
223+ return nil , err
224+ }
225+ return & controllerInfo {
226+ controllerKey : controllerKey {Namespace : pod .Namespace , Kind : "StatefulSet" , Name : sts .Name },
227+ Selector : sts .Spec .Selector ,
228+ }, nil
229+ default :
230+ // DaemonSet (skipped before reaching here), Job (TTL), CronJob,
231+ // custom controllers — none get a temporary PDB.
232+ return nil , nil
233+ }
234+ }
235+
236+ // getCached returns the object identified by (ns, name) from cache, fetching it
237+ // via get — the namespace-bound typed clientset accessor, e.g.
238+ // clientset.AppsV1().ReplicaSets(ns).Get — and populating the cache on a miss.
239+ // T is inferred from the cache's value type, collapsing the per-kind getters
240+ // into a single generic lookup.
241+ func getCached [T any ](
242+ ctx context.Context ,
243+ cache map [client.ObjectKey ]* T ,
244+ ns , name string ,
245+ get func (ctx context.Context , name string , opts metav1.GetOptions ) (* T , error ),
246+ ) (* T , error ) {
247+ key := client.ObjectKey {Namespace : ns , Name : name }
248+ if obj , ok := cache [key ]; ok {
249+ return obj , nil
250+ }
251+ obj , err := get (ctx , name , metav1.GetOptions {})
252+ if err != nil {
253+ return nil , err
254+ }
255+ cache [key ] = obj
256+ return obj , nil
257+ }
258+
259+ // listNamespacePDBs returns every PDB in the namespace. Used to detect
260+ // pre-existing user PDBs covering a controller we'd otherwise PDB-protect.
261+ func listNamespacePDBs (ctx context.Context , clientset kubernetes.Interface , namespace string ) ([]policyv1.PodDisruptionBudget , error ) {
262+ list , err := clientset .PolicyV1 ().PodDisruptionBudgets (namespace ).List (ctx , metav1.ListOptions {})
263+ if err != nil {
264+ return nil , err
265+ }
266+ return list .Items , nil
267+ }
268+
269+ // hasUserPDB returns true when an existing non-temporary PDB has the same
270+ // selector as the controller's pod selector. This is a conservative
271+ // equality check: a broader user PDB will NOT be detected, and we'll create
272+ // our own. Eviction will then respect the most restrictive of the two,
273+ // preserving the user's intent.
274+ func hasUserPDB (existing []policyv1.PodDisruptionBudget , controllerSelector * metav1.LabelSelector ) bool {
275+ if controllerSelector == nil {
276+ return false
277+ }
278+ return slices .ContainsFunc (existing , func (pdb policyv1.PodDisruptionBudget ) bool {
279+ return ! isTemporaryPDB (& pdb ) && reflect .DeepEqual (pdb .Spec .Selector , controllerSelector )
280+ })
281+ }
282+
283+ func isTemporaryPDB (pdb * policyv1.PodDisruptionBudget ) bool {
284+ return pdb .Labels [pdbManagedByLabelKey ] == pdbManagedByLabelValue &&
285+ pdb .Labels [pdbTempLabelKey ] == pdbTempLabelValue
286+ }
287+
288+ // createTempPDB writes (or no-ops if our PDB already exists) a temporary
289+ // PodDisruptionBudget with maxUnavailable: 1. Existing PDBs that aren't ours
290+ // at the same name are left alone with a logged warning — that's a name
291+ // collision the user must resolve.
292+ func createTempPDB (ctx context.Context , ctrlClient client.Client , c controllerInfo , dryRun bool ) error {
293+ name := tempPDBName (c .Kind , c .Name )
294+ if dryRun {
295+ log .Printf ("[dry-run] would create PDB %s/%s (maxUnavailable: 1, selector: %s)" , c .Namespace , name , formatSelector (c .Selector ))
296+ return nil
297+ }
298+
299+ existing := & policyv1.PodDisruptionBudget {}
300+ err := ctrlClient .Get (ctx , client.ObjectKey {Namespace : c .Namespace , Name : name }, existing )
301+ switch {
302+ case err == nil :
303+ if ! isTemporaryPDB (existing ) {
304+ log .Printf ("Warning: PDB %s/%s exists but is not labelled as temporary; leaving it untouched" , c .Namespace , name )
305+ return nil
306+ }
307+ // Our PDB from a previous (possibly crashed) run. Leave as-is; the
308+ // cleanup step will remove it at the end of the current run.
309+ return nil
310+ case ! apierrors .IsNotFound (err ):
311+ return fmt .Errorf ("failed to get PDB %s/%s: %w" , c .Namespace , name , err )
312+ }
313+
314+ maxUnavailable := intstr .FromInt (1 )
315+ pdb := & policyv1.PodDisruptionBudget {
316+ TypeMeta : metav1.TypeMeta {APIVersion : "policy/v1" , Kind : "PodDisruptionBudget" },
317+ ObjectMeta : metav1.ObjectMeta {
318+ Name : name ,
319+ Namespace : c .Namespace ,
320+ Labels : map [string ]string {
321+ pdbManagedByLabelKey : pdbManagedByLabelValue ,
322+ pdbTempLabelKey : pdbTempLabelValue ,
323+ },
324+ },
325+ Spec : policyv1.PodDisruptionBudgetSpec {
326+ Selector : c .Selector .DeepCopy (),
327+ MaxUnavailable : & maxUnavailable ,
328+ },
329+ }
330+ if err := ctrlClient .Create (ctx , pdb ); err != nil {
331+ return fmt .Errorf ("failed to create PDB %s/%s: %w" , c .Namespace , name , err )
332+ }
333+ log .Printf ("Created temporary PDB %s/%s for %s/%s (maxUnavailable: 1)." , c .Namespace , name , c .Kind , c .Name )
334+ return nil
335+ }
336+
337+ // tempPDBName builds a DNS-label-safe PDB name. Long controller names are
338+ // truncated so the final name (including the suffix) fits the 63-char limit.
339+ func tempPDBName (kind , controllerName string ) string {
340+ prefix := strings .ToLower (kind ) + "-" + controllerName
341+ if len (prefix )+ len (pdbNameSuffix ) > validation .DNS1123LabelMaxLength {
342+ prefix = prefix [:validation .DNS1123LabelMaxLength - len (pdbNameSuffix )]
343+ }
344+ return prefix + pdbNameSuffix
345+ }
346+
347+ func formatSelector (s * metav1.LabelSelector ) string {
348+ if s == nil {
349+ return "<nil>"
350+ }
351+ parts := lo .MapToSlice (s .MatchLabels , func (k , v string ) string {
352+ return k + "=" + v
353+ })
354+ return strings .Join (parts , "," )
29355}
0 commit comments