Skip to content

Commit 84605ab

Browse files
authored
[CASCL-1386] (2/11) Build the evict-legacy-nodes execution plan (#3161)
* [CASCL-1386] Add evict-legacy-nodes command skeleton 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. * [CASCL-1386] Implement evict-legacy-nodes execution plan building 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.
1 parent 75154e1 commit 84605ab

2 files changed

Lines changed: 296 additions & 2 deletions

File tree

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

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ package evict
33
import (
44
"errors"
55
"fmt"
6+
"maps"
7+
"slices"
68
"strings"
79

10+
"github.com/samber/lo"
11+
812
"github.com/DataDog/datadog-operator/cmd/kubectl-datadog/autoscaling/cluster/common/clusterinfo"
913
)
1014

@@ -54,9 +58,82 @@ func ParseTargetSpec(raw string) (Target, error) {
5458
}
5559

5660
func BuildPlan(info *clusterinfo.ClusterInfo, all bool, specs []Target) ([]Target, error) {
57-
panic("TODO: BuildPlan — implemented in PR https://github.com/DataDog/datadog-operator/pull/3161")
61+
if info == nil {
62+
return nil, errors.New("cluster info is nil")
63+
}
64+
if all {
65+
return buildAllPlan(info), nil
66+
}
67+
return buildTargetedPlan(info, specs)
68+
}
69+
70+
func buildAllPlan(info *clusterinfo.ClusterInfo) []Target {
71+
var targets []Target
72+
for _, mgr := range []clusterinfo.NodeManager{
73+
clusterinfo.NodeManagerKarpenter,
74+
clusterinfo.NodeManagerEKSManagedNodeGroup,
75+
clusterinfo.NodeManagerASG,
76+
clusterinfo.NodeManagerStandalone,
77+
} {
78+
bucket, ok := info.NodeManagement[mgr]
79+
if !ok {
80+
continue
81+
}
82+
targets = append(targets, lo.FilterMap(slices.Sorted(maps.Keys(bucket)), func(name string, _ int) (Target, bool) {
83+
entry := bucket[name]
84+
if entry.ManagedByDatadog {
85+
return Target{}, false
86+
}
87+
return Target{
88+
Manager: mgr,
89+
Entity: name,
90+
Nodes: entry.Nodes,
91+
}, true
92+
})...)
93+
}
94+
return targets
95+
}
96+
97+
func buildTargetedPlan(info *clusterinfo.ClusterInfo, specs []Target) ([]Target, error) {
98+
if len(specs) == 0 {
99+
return nil, errors.New("at least one --target must be provided, or --all")
100+
}
101+
var (
102+
targets []Target
103+
errs []error
104+
)
105+
for _, t := range specs {
106+
bucket, ok := info.NodeManagement[t.Manager]
107+
if !ok {
108+
errs = append(errs, fmt.Errorf("--target=%s/%s: no %s entities found in the cluster snapshot", t.Manager, t.Entity, t.Manager))
109+
continue
110+
}
111+
entry, ok := bucket[t.Entity]
112+
if !ok {
113+
errs = append(errs, fmt.Errorf("--target=%s/%s: entity not found in the cluster snapshot", t.Manager, t.Entity))
114+
continue
115+
}
116+
if entry.ManagedByDatadog {
117+
errs = append(errs, fmt.Errorf("--target=%s/%s: this entity is managed by Datadog and cannot be evicted", t.Manager, t.Entity))
118+
continue
119+
}
120+
targets = append(targets, Target{
121+
Manager: t.Manager,
122+
Entity: t.Entity,
123+
Nodes: entry.Nodes,
124+
})
125+
}
126+
if len(errs) > 0 {
127+
return nil, errors.Join(errs...)
128+
}
129+
return targets, nil
58130
}
59131

60132
func hasDatadogManagedNodePool(info *clusterinfo.ClusterInfo) bool {
61-
panic("TODO: hasDatadogManagedNodePool — implemented in PR https://github.com/DataDog/datadog-operator/pull/3161")
133+
for _, entry := range info.NodeManagement[clusterinfo.NodeManagerKarpenter] {
134+
if entry.ManagedByDatadog {
135+
return true
136+
}
137+
}
138+
return false
62139
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package evict
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/DataDog/datadog-operator/cmd/kubectl-datadog/autoscaling/cluster/common/clusterinfo"
10+
)
11+
12+
func TestParseTargetSpec(t *testing.T) {
13+
for _, tc := range []struct {
14+
name string
15+
input string
16+
want Target
17+
wantError string
18+
}{
19+
{name: "asg", input: "asg/legacy-asg", want: Target{Manager: clusterinfo.NodeManagerASG, Entity: "legacy-asg"}},
20+
{name: "eks-mng", input: "eksManagedNodeGroup/standard-2", want: Target{Manager: clusterinfo.NodeManagerEKSManagedNodeGroup, Entity: "standard-2"}},
21+
{name: "karpenter", input: "karpenter/user-pool", want: Target{Manager: clusterinfo.NodeManagerKarpenter, Entity: "user-pool"}},
22+
{name: "standalone-no-slash", input: "standalone", want: Target{Manager: clusterinfo.NodeManagerStandalone, Entity: ""}},
23+
{name: "standalone-empty-after-slash", input: "standalone/", want: Target{Manager: clusterinfo.NodeManagerStandalone, Entity: ""}},
24+
{name: "leading-trailing-spaces", input: " asg/foo ", want: Target{Manager: clusterinfo.NodeManagerASG, Entity: "foo"}},
25+
26+
{name: "empty", input: "", wantError: "cannot be empty"},
27+
{name: "asg-no-name", input: "asg", wantError: "requires a name"},
28+
{name: "asg-empty-name", input: "asg/", wantError: "requires a name"},
29+
{name: "standalone-with-name", input: "standalone/foo", wantError: "does not take a name"},
30+
{name: "fargate-rejected", input: "fargate/profile", wantError: "not supported"},
31+
{name: "unknown-rejected", input: "unknown/x", wantError: "not supported"},
32+
{name: "garbage", input: "blob/x", wantError: "unknown manager"},
33+
} {
34+
t.Run(tc.name, func(t *testing.T) {
35+
got, err := ParseTargetSpec(tc.input)
36+
if tc.wantError != "" {
37+
assert.ErrorContains(t, err, tc.wantError)
38+
} else {
39+
require.NoError(t, err)
40+
assert.Equal(t, tc.want, got)
41+
}
42+
})
43+
}
44+
}
45+
46+
func TestBuildPlan(t *testing.T) {
47+
mixedInfo := &clusterinfo.ClusterInfo{
48+
NodeManagement: map[clusterinfo.NodeManager]map[string]clusterinfo.NodeManagerEntry{
49+
clusterinfo.NodeManagerASG: {
50+
"legacy-asg-a": {Nodes: []string{"ip-1", "ip-2"}, ManagedByDatadog: false},
51+
"legacy-asg-b": {Nodes: []string{"ip-3"}, ManagedByDatadog: false},
52+
},
53+
clusterinfo.NodeManagerEKSManagedNodeGroup: {
54+
"mng-1": {Nodes: []string{"ip-4"}, ManagedByDatadog: false},
55+
"datadog-owned": {Nodes: []string{"ip-5"}, ManagedByDatadog: true},
56+
},
57+
clusterinfo.NodeManagerKarpenter: {
58+
"user-np": {Nodes: []string{"ip-6"}, ManagedByDatadog: false},
59+
"dd-np": {Nodes: []string{"ip-7"}, ManagedByDatadog: true},
60+
},
61+
clusterinfo.NodeManagerStandalone: {
62+
"": {Nodes: []string{"ip-8"}, ManagedByDatadog: false},
63+
},
64+
clusterinfo.NodeManagerFargate: {
65+
"profile-1": {Nodes: []string{"fargate-ip-9"}, ManagedByDatadog: false},
66+
},
67+
clusterinfo.NodeManagerUnknown: {
68+
"": {Nodes: []string{"weird-1"}, ManagedByDatadog: false},
69+
},
70+
},
71+
}
72+
asgOnlyInfo := &clusterinfo.ClusterInfo{
73+
NodeManagement: map[clusterinfo.NodeManager]map[string]clusterinfo.NodeManagerEntry{
74+
clusterinfo.NodeManagerASG: {
75+
"my-asg": {Nodes: []string{"ip-1", "ip-2"}, ManagedByDatadog: false},
76+
},
77+
},
78+
}
79+
ddASGInfo := &clusterinfo.ClusterInfo{
80+
NodeManagement: map[clusterinfo.NodeManager]map[string]clusterinfo.NodeManagerEntry{
81+
clusterinfo.NodeManagerASG: {
82+
"dd-asg": {Nodes: []string{"ip-1"}, ManagedByDatadog: true},
83+
},
84+
},
85+
}
86+
87+
for _, tc := range []struct {
88+
name string
89+
info *clusterinfo.ClusterInfo
90+
all bool
91+
targets []Target
92+
93+
wantErr bool
94+
wantErrContains string
95+
// wantEntities, when non-nil, is the expected set of `<manager>/<entity>`
96+
// strings in the returned plan. ElementsMatch is used (order
97+
// irrelevant). nil disables the check.
98+
wantEntities []string
99+
// wantNodes maps entity name to its expected Nodes slice in the
100+
// returned plan (only used in the targeted happy-path case).
101+
wantNodes map[string][]string
102+
}{
103+
{
104+
// --all: Datadog-managed entries dropped; fargate + unknown
105+
// skipped entirely.
106+
name: "--all returns every non-Datadog entity",
107+
info: mixedInfo,
108+
all: true,
109+
wantEntities: []string{
110+
"karpenter/user-np",
111+
"eksManagedNodeGroup/mng-1",
112+
"asg/legacy-asg-a",
113+
"asg/legacy-asg-b",
114+
"standalone/",
115+
},
116+
},
117+
{
118+
name: "targeted happy path",
119+
info: asgOnlyInfo,
120+
targets: []Target{{Manager: clusterinfo.NodeManagerASG, Entity: "my-asg"}},
121+
wantEntities: []string{"asg/my-asg"},
122+
wantNodes: map[string][]string{"my-asg": {"ip-1", "ip-2"}},
123+
},
124+
{
125+
name: "missing entity errors",
126+
info: ddASGInfo,
127+
targets: []Target{{Manager: clusterinfo.NodeManagerASG, Entity: "ghost"}},
128+
wantErr: true,
129+
wantErrContains: "not found",
130+
},
131+
{
132+
name: "datadog-managed entity is refused",
133+
info: ddASGInfo,
134+
targets: []Target{{Manager: clusterinfo.NodeManagerASG, Entity: "dd-asg"}},
135+
wantErr: true,
136+
wantErrContains: "managed by Datadog",
137+
},
138+
{
139+
name: "missing manager bucket errors",
140+
info: ddASGInfo,
141+
targets: []Target{{Manager: clusterinfo.NodeManagerKarpenter, Entity: "x"}},
142+
wantErr: true,
143+
wantErrContains: "no karpenter entities",
144+
},
145+
{
146+
name: "no targets and not --all errors",
147+
info: ddASGInfo,
148+
wantErr: true,
149+
wantErrContains: "at least one --target",
150+
},
151+
} {
152+
t.Run(tc.name, func(t *testing.T) {
153+
got, err := BuildPlan(tc.info, tc.all, tc.targets)
154+
if tc.wantErr {
155+
require.Error(t, err)
156+
if tc.wantErrContains != "" {
157+
assert.Contains(t, err.Error(), tc.wantErrContains)
158+
}
159+
return
160+
}
161+
require.NoError(t, err)
162+
if tc.wantEntities != nil {
163+
entities := make([]string, 0, len(got))
164+
for _, g := range got {
165+
entities = append(entities, string(g.Manager)+"/"+g.Entity)
166+
}
167+
assert.ElementsMatch(t, tc.wantEntities, entities)
168+
}
169+
for entity, wantNodes := range tc.wantNodes {
170+
found := false
171+
for _, g := range got {
172+
if g.Entity == entity {
173+
assert.Equal(t, wantNodes, g.Nodes)
174+
found = true
175+
break
176+
}
177+
}
178+
assert.True(t, found, "expected entity %q in plan", entity)
179+
}
180+
})
181+
}
182+
}
183+
184+
func TestHasDatadogManagedNodePool(t *testing.T) {
185+
for _, tc := range []struct {
186+
name string
187+
info *clusterinfo.ClusterInfo
188+
want bool
189+
}{
190+
{
191+
name: "no karpenter bucket",
192+
info: &clusterinfo.ClusterInfo{NodeManagement: map[clusterinfo.NodeManager]map[string]clusterinfo.NodeManagerEntry{}},
193+
want: false,
194+
},
195+
{
196+
name: "only user NodePools",
197+
info: &clusterinfo.ClusterInfo{NodeManagement: map[clusterinfo.NodeManager]map[string]clusterinfo.NodeManagerEntry{
198+
clusterinfo.NodeManagerKarpenter: {"user-np": {ManagedByDatadog: false}},
199+
}},
200+
want: false,
201+
},
202+
{
203+
name: "at least one Datadog NodePool",
204+
info: &clusterinfo.ClusterInfo{NodeManagement: map[clusterinfo.NodeManager]map[string]clusterinfo.NodeManagerEntry{
205+
clusterinfo.NodeManagerKarpenter: {
206+
"user-np": {ManagedByDatadog: false},
207+
"dd-np": {ManagedByDatadog: true},
208+
},
209+
}},
210+
want: true,
211+
},
212+
} {
213+
t.Run(tc.name, func(t *testing.T) {
214+
assert.Equal(t, tc.want, hasDatadogManagedNodePool(tc.info))
215+
})
216+
}
217+
}

0 commit comments

Comments
 (0)