Skip to content

Commit 6a978ac

Browse files
L3n41cclaude
andcommitted
[CASCL-1386] Create temporary PodDisruptionBudgets during eviction
Part of a stack splitting #3026 (too large to review in one piece) into small pieces that each build and pass tests on their own. The command is fully functional only once the whole stack lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e6ebd52 commit 6a978ac

3 files changed

Lines changed: 761 additions & 2 deletions

File tree

cmd/kubectl-datadog/autoscaling/cluster/evict/pdb.go

Lines changed: 328 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,41 @@ package evict
22

33
import (
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.
1951
func 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.
27100
func 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

Comments
 (0)