Skip to content

Commit 2d19db4

Browse files
authored
feat: Add option to specify the assignment rules for scaling policy (#505)
1 parent 6ab9365 commit 2d19db4

File tree

4 files changed

+471
-2
lines changed

4 files changed

+471
-2
lines changed

castai/resource_workload_scaling_policy.go

Lines changed: 326 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ const (
4747
FieldApplyThresholdStrategyPercentageType = "PERCENTAGE"
4848
FieldApplyThresholdStrategyDefaultAdaptiveType = "DEFAULT_ADAPTIVE"
4949
FieldApplyThresholdStrategyCustomAdaptiveType = "CUSTOM_ADAPTIVE"
50+
FieldAssignmentRules = "assignment_rules"
51+
)
52+
53+
const (
54+
K8sLabelInOperator = "In"
55+
K8sLabelNotInOperator = "NotIn"
56+
K8sLabelExistsOperator = "Exists"
57+
K8sLabelDoesNotExistOperator = "DoesNotExist"
5058
)
5159

5260
var (
@@ -72,6 +80,63 @@ func resourceWorkloadScalingPolicy() *schema.Resource {
7280
Description: "CAST AI cluster id",
7381
ValidateDiagFunc: validation.ToDiagFunc(validation.IsUUID),
7482
},
83+
FieldAssignmentRules: {
84+
Type: schema.TypeList,
85+
Optional: true,
86+
Description: "Allows defining conditions for automatically assigning workloads to this scaling policy.",
87+
Elem: &schema.Resource{
88+
Schema: map[string]*schema.Schema{
89+
"rules": {
90+
Type: schema.TypeList,
91+
Required: true,
92+
Elem: &schema.Resource{
93+
Schema: map[string]*schema.Schema{
94+
"namespace": {
95+
Type: schema.TypeList,
96+
Optional: true,
97+
MaxItems: 1,
98+
Description: "Allows assigning a scaling policy based on the workload's namespace.",
99+
Elem: &schema.Resource{
100+
Schema: map[string]*schema.Schema{
101+
"names": {
102+
Type: schema.TypeList,
103+
Optional: true,
104+
Description: "Defines matching by namespace names.",
105+
Elem: &schema.Schema{Type: schema.TypeString},
106+
},
107+
// TODO(WOOP-714): enable label expressions
108+
//"labels_expressions": k8sLabelExpressionsSchema(),
109+
},
110+
},
111+
},
112+
"workload": {
113+
Type: schema.TypeList,
114+
Optional: true,
115+
MaxItems: 1,
116+
Description: "Allows assigning a scaling policy based on the workload's metadata.",
117+
Elem: &schema.Resource{
118+
Schema: map[string]*schema.Schema{
119+
"gvk": {
120+
Type: schema.TypeList,
121+
Optional: true,
122+
Description: `Group, version, and kind for Kubernetes resources. Format: kind[.version][.group].
123+
It can be either:
124+
- only kind, e.g. "Deployment"
125+
- group and kind: e.g."Deployment.apps"
126+
- group, version and kind: e.g."Deployment.v1.apps"`,
127+
128+
Elem: &schema.Schema{Type: schema.TypeString},
129+
},
130+
"labels_expressions": k8sLabelExpressionsSchema(),
131+
},
132+
},
133+
},
134+
},
135+
},
136+
},
137+
},
138+
},
139+
},
75140
"name": {
76141
Type: schema.TypeString,
77142
Required: true,
@@ -200,6 +265,37 @@ func resourceWorkloadScalingPolicy() *schema.Resource {
200265
}
201266
}
202267

268+
func k8sLabelExpressionsSchema() *schema.Schema {
269+
return &schema.Schema{
270+
Type: schema.TypeList,
271+
Optional: true,
272+
Description: "Defines matching by label selector requirements.",
273+
Elem: &schema.Resource{
274+
Schema: map[string]*schema.Schema{
275+
"key": {
276+
Type: schema.TypeString,
277+
Required: true,
278+
Description: "The label key to match.",
279+
},
280+
"operator": {
281+
Type: schema.TypeString,
282+
Required: true,
283+
Description: "The operator to use for matching the label.",
284+
ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{
285+
K8sLabelInOperator, K8sLabelNotInOperator, K8sLabelExistsOperator, K8sLabelDoesNotExistOperator,
286+
}, false)),
287+
},
288+
"values": {
289+
Type: schema.TypeList,
290+
Optional: true,
291+
Description: "A list of values to match against the label key. Allowed for `In` and `NotIn` operators.",
292+
Elem: &schema.Schema{Type: schema.TypeString},
293+
},
294+
},
295+
},
296+
}
297+
}
298+
203299
func workloadScalingPolicyResourceSchema(resource, function string, overhead, minRecommended float64) *schema.Resource {
204300
return &schema.Resource{
205301
Schema: map[string]*schema.Schema{
@@ -414,6 +510,12 @@ func resourceWorkloadScalingPolicyCreate(ctx context.Context, d *schema.Resource
414510

415511
req.RecommendationPolicies.AntiAffinity = toAntiAffinity(toSection(d, "anti_affinity"))
416512

513+
ar, err := toAssignmentRules(toSection(d, FieldAssignmentRules))
514+
if err != nil {
515+
return diag.FromErr(err)
516+
}
517+
req.AssignmentRules = ar
518+
417519
create, err := client.WorkloadOptimizationAPICreateWorkloadScalingPolicyWithResponse(ctx, clusterID, req)
418520
if err != nil {
419521
return diag.FromErr(err)
@@ -489,6 +591,10 @@ func resourceWorkloadScalingPolicyRead(ctx context.Context, d *schema.ResourceDa
489591
return diag.FromErr(fmt.Errorf("setting anti-affinity: %w", err))
490592
}
491593

594+
if err := d.Set(FieldAssignmentRules, toAssignmentRulesMap(getResourceFrom(d, FieldAssignmentRules), sp.AssignmentRules)); err != nil {
595+
return diag.FromErr(fmt.Errorf("setting assignment rules: %w", err))
596+
}
597+
492598
return nil
493599
}
494600

@@ -511,6 +617,7 @@ func resourceWorkloadScalingPolicyUpdate(ctx context.Context, d *schema.Resource
511617
"memory_event",
512618
"anti_affinity",
513619
FieldConfidence,
620+
FieldAssignmentRules,
514621
) {
515622
tflog.Info(ctx, "scaling policy up to date")
516623
return nil
@@ -526,9 +633,15 @@ func resourceWorkloadScalingPolicyUpdate(ctx context.Context, d *schema.Resource
526633
if err != nil {
527634
return diag.FromErr(err)
528635
}
636+
ar, err := toAssignmentRules(toSection(d, FieldAssignmentRules))
637+
if err != nil {
638+
return diag.FromErr(err)
639+
}
640+
529641
req := sdk.WorkloadOptimizationAPIUpdateWorkloadScalingPolicyJSONRequestBody{
530-
Name: d.Get("name").(string),
531-
ApplyType: sdk.WorkloadoptimizationV1ApplyType(d.Get("apply_type").(string)),
642+
Name: d.Get("name").(string),
643+
ApplyType: sdk.WorkloadoptimizationV1ApplyType(d.Get("apply_type").(string)),
644+
AssignmentRules: ar,
532645
RecommendationPolicies: sdk.WorkloadoptimizationV1RecommendationPolicies{
533646
ManagementOption: sdk.WorkloadoptimizationV1ManagementOption(d.Get("management_option").(string)),
534647
Cpu: cpu,
@@ -1058,3 +1171,214 @@ func getWorkloadScalingPolicyByName(ctx context.Context, client sdk.ClientWithRe
10581171
}
10591172
return nil, fmt.Errorf("policy with name %q not found", name)
10601173
}
1174+
1175+
func toAssignmentRules(in map[string]any) (*[]sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule, error) {
1176+
if len(in) == 0 {
1177+
return nil, nil
1178+
}
1179+
1180+
rules, ok := in["rules"].([]any)
1181+
if !ok || len(rules) == 0 {
1182+
return &[]sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule{}, nil
1183+
}
1184+
1185+
result := make([]sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule, len(rules))
1186+
for i, rule := range rules {
1187+
ruleMap := rule.(map[string]any)
1188+
1189+
nsRules, err := toNamespaceAssignmentRule(ruleMap)
1190+
if err != nil {
1191+
return nil, fmt.Errorf("field assignment_rules[%d].namespace: %w", i, err)
1192+
}
1193+
1194+
wRules, err := toWorkloadAssignmentRule(ruleMap)
1195+
if err != nil {
1196+
return nil, fmt.Errorf("field assignment_rules[%d].workload: %w", i, err)
1197+
}
1198+
if nsRules == nil && wRules == nil {
1199+
continue
1200+
}
1201+
result[i] = sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule{
1202+
Workload: wRules,
1203+
Namespace: nsRules,
1204+
}
1205+
}
1206+
1207+
return &result, nil
1208+
}
1209+
1210+
func toNamespaceAssignmentRule(ruleMap map[string]any) (*sdk.WorkloadoptimizationV1KubernetesNamespaceMatcher, error) {
1211+
namespaceMap := getFirstElem(ruleMap, "namespace")
1212+
if namespaceMap == nil {
1213+
return nil, nil
1214+
}
1215+
1216+
namespaceMatcher := &sdk.WorkloadoptimizationV1KubernetesNamespaceMatcher{}
1217+
1218+
if names := readOptionalValue[[]any](namespaceMap, "names"); names != nil {
1219+
namespaceMatcher.Names = lo.ToPtr(toStringList(*names))
1220+
}
1221+
1222+
labels, err := toKubernetesLabelExpressionMatcher(namespaceMap)
1223+
if err != nil {
1224+
return nil, err
1225+
}
1226+
namespaceMatcher.LabelsExpressions = labels
1227+
1228+
return namespaceMatcher, nil
1229+
}
1230+
1231+
func toKubernetesLabelExpressionMatcher(namespaceMap map[string]any) (*[]sdk.WorkloadoptimizationV1KubernetesLabelExpressionMatcher, error) {
1232+
exprs := readOptionalValue[[]any](namespaceMap, "labels_expressions")
1233+
if exprs == nil {
1234+
return nil, nil
1235+
}
1236+
expressions := make([]sdk.WorkloadoptimizationV1KubernetesLabelExpressionMatcher, len(*exprs))
1237+
for j, expr := range *exprs {
1238+
exprMap := expr.(map[string]any)
1239+
key, err := mustGetValue[string](exprMap, "key")
1240+
if err != nil {
1241+
return nil, err
1242+
}
1243+
1244+
operator, err := mustGetValue[string](exprMap, "operator")
1245+
if err != nil {
1246+
return nil, err
1247+
}
1248+
1249+
expressions[j] = sdk.WorkloadoptimizationV1KubernetesLabelExpressionMatcher{
1250+
Key: *key,
1251+
Operator: toLabelSelectorOperator(*operator),
1252+
}
1253+
1254+
values := readOptionalValue[[]any](exprMap, "values")
1255+
if values != nil {
1256+
expressions[j].Values = toStringList(*values)
1257+
}
1258+
}
1259+
return &expressions, nil
1260+
}
1261+
1262+
func toWorkloadAssignmentRule(ruleMap map[string]any) (*sdk.WorkloadoptimizationV1KubernetesWorkloadMatcher, error) {
1263+
workloadMap := getFirstElem(ruleMap, "workload")
1264+
if workloadMap == nil {
1265+
return nil, nil
1266+
}
1267+
1268+
workloadMatcher := &sdk.WorkloadoptimizationV1KubernetesWorkloadMatcher{}
1269+
1270+
if gvk := readOptionalValue[[]any](workloadMap, "gvk"); gvk != nil {
1271+
workloadMatcher.Gvk = lo.ToPtr(toStringList(*gvk))
1272+
}
1273+
1274+
labels, err := toKubernetesLabelExpressionMatcher(workloadMap)
1275+
if err != nil {
1276+
return nil, err
1277+
}
1278+
workloadMatcher.LabelsExpressions = labels
1279+
1280+
return workloadMatcher, nil
1281+
}
1282+
1283+
func getFirstElem(in map[string]any, key string) map[string]any {
1284+
list, ok := in[key].([]any)
1285+
if !ok || len(list) == 0 {
1286+
return nil
1287+
}
1288+
elem, ok := list[0].(map[string]any)
1289+
if !ok {
1290+
return nil
1291+
}
1292+
return elem
1293+
}
1294+
1295+
func toLabelSelectorOperator(in string) sdk.WorkloadoptimizationV1KubernetesLabelSelectorOperator {
1296+
switch in {
1297+
case K8sLabelInOperator:
1298+
return sdk.KUBERNETESLABELSELECTOROPIN
1299+
case K8sLabelNotInOperator:
1300+
return sdk.KUBERNETESLABELSELECTOROPNOTIN
1301+
case K8sLabelExistsOperator:
1302+
return sdk.KUBERNETESLABELSELECTOROPEXISTS
1303+
case K8sLabelDoesNotExistOperator:
1304+
return sdk.KUBERNETESLABELSELECTOROPDOESNOTEXIST
1305+
}
1306+
return sdk.KUBERNETESLABELSELECTOROPUNSPECIFIED
1307+
}
1308+
1309+
func toAssignmentRulesMap(previous map[string]any, rules *[]sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule) []any {
1310+
if rules == nil {
1311+
return nil
1312+
}
1313+
1314+
if len(*rules) == 0 && len(previous) == 0 {
1315+
return nil
1316+
}
1317+
1318+
result := make([]map[string]any, len(*rules))
1319+
for i, rule := range *rules {
1320+
ruleMap := make(map[string]any)
1321+
1322+
if rule.Namespace != nil {
1323+
namespaceMap := make(map[string]any)
1324+
1325+
if rule.Namespace.Names != nil {
1326+
namespaceMap["names"] = *rule.Namespace.Names
1327+
}
1328+
1329+
if rule.Namespace.LabelsExpressions != nil && len(*rule.Namespace.LabelsExpressions) > 0 {
1330+
namespaceMap["labels_expressions"] = toK8sLabelsExpressionsMap(*rule.Namespace.LabelsExpressions)
1331+
}
1332+
1333+
ruleMap["namespace"] = []map[string]any{namespaceMap}
1334+
}
1335+
1336+
if rule.Workload != nil {
1337+
workloadMap := make(map[string]any)
1338+
1339+
if rule.Workload.Gvk != nil && len(*rule.Workload.Gvk) > 0 {
1340+
workloadMap["gvk"] = *rule.Workload.Gvk
1341+
}
1342+
1343+
if rule.Workload.LabelsExpressions != nil && len(*rule.Workload.LabelsExpressions) > 0 {
1344+
workloadMap["labels_expressions"] = toK8sLabelsExpressionsMap(*rule.Workload.LabelsExpressions)
1345+
}
1346+
1347+
ruleMap["workload"] = []map[string]any{workloadMap}
1348+
}
1349+
1350+
result[i] = ruleMap
1351+
}
1352+
1353+
return []any{
1354+
map[string]any{
1355+
"rules": result,
1356+
},
1357+
}
1358+
}
1359+
1360+
func toK8sLabelsExpressionsMap(in []sdk.WorkloadoptimizationV1KubernetesLabelExpressionMatcher) []map[string]any {
1361+
expressions := make([]map[string]any, len(in))
1362+
for j, expr := range in {
1363+
expressions[j] = map[string]any{
1364+
"key": expr.Key,
1365+
"operator": labelSelectorOperatorMap(expr.Operator),
1366+
"values": expr.Values,
1367+
}
1368+
}
1369+
return expressions
1370+
}
1371+
1372+
func labelSelectorOperatorMap(in sdk.WorkloadoptimizationV1KubernetesLabelSelectorOperator) string {
1373+
switch in {
1374+
case sdk.KUBERNETESLABELSELECTOROPIN:
1375+
return K8sLabelInOperator
1376+
case sdk.KUBERNETESLABELSELECTOROPNOTIN:
1377+
return K8sLabelNotInOperator
1378+
case sdk.KUBERNETESLABELSELECTOROPEXISTS:
1379+
return K8sLabelExistsOperator
1380+
case sdk.KUBERNETESLABELSELECTOROPDOESNOTEXIST:
1381+
return K8sLabelDoesNotExistOperator
1382+
}
1383+
return "unspecified"
1384+
}

0 commit comments

Comments
 (0)