Skip to content

Commit b6246ad

Browse files
committed
feat: Add option to specify the assignment rules for scaling policy
1 parent e4ce0e7 commit b6246ad

File tree

4 files changed

+491
-3
lines changed

4 files changed

+491
-3
lines changed

castai/resource_workload_scaling_policy.go

Lines changed: 343 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ const (
4949
FieldApplyThresholdStrategyCustomAdaptiveType = "CUSTOM_ADAPTIVE"
5050
)
5151

52+
const (
53+
K8sLabelInOperator = "In"
54+
K8sLabelNotInOperator = "NotIn"
55+
K8sLabelExistsOperator = "Exists"
56+
K8sLabelDoesNotExistOperator = "DoesNotExist"
57+
)
58+
5259
var (
5360
k8sNameRegex = regexp.MustCompile("^[a-z0-9A-Z][a-z0-9A-Z._-]{0,61}[a-z0-9A-Z]$")
5461
)
@@ -72,6 +79,73 @@ func resourceWorkloadScalingPolicy() *schema.Resource {
7279
Description: "CAST AI cluster id",
7380
ValidateDiagFunc: validation.ToDiagFunc(validation.IsUUID),
7481
},
82+
"assignment_rules": {
83+
Type: schema.TypeList,
84+
Optional: true,
85+
Description: "Allows defining conditions for automatically assigning workloads to this scaling policy.",
86+
Elem: &schema.Resource{
87+
Schema: map[string]*schema.Schema{
88+
"rules": {
89+
Type: schema.TypeList,
90+
Optional: true,
91+
Elem: &schema.Resource{
92+
Schema: map[string]*schema.Schema{
93+
"namespace": {
94+
Type: schema.TypeList,
95+
Optional: true,
96+
MaxItems: 1,
97+
Description: "Allows assigning a scaling policy based on the workload's namespace.",
98+
Elem: &schema.Resource{
99+
Schema: map[string]*schema.Schema{
100+
"all": { // TODO: sync with Sandra about that field.
101+
Type: schema.TypeBool,
102+
Optional: true,
103+
Description: "Defines matching all namespaces. Cannot be set together with other matchers.",
104+
},
105+
"names": {
106+
Type: schema.TypeList,
107+
Optional: true,
108+
Description: "Defines matching by namespace names.",
109+
Elem: &schema.Schema{Type: schema.TypeString},
110+
},
111+
// TODO(https://castai.atlassian.net/browse/WOOP-714): enable label expressions
112+
//"labels_expressions": k8sLabelExpressionsSchema(),
113+
},
114+
},
115+
},
116+
"workload": {
117+
Type: schema.TypeList,
118+
Optional: true,
119+
MaxItems: 1,
120+
Description: "Allows assigning a scaling policy based on the workload's metadata.",
121+
Elem: &schema.Resource{
122+
Schema: map[string]*schema.Schema{
123+
"all": { // TODO: sync with Sandra about that field.
124+
Type: schema.TypeBool,
125+
Optional: true,
126+
Description: "Defines matching all workloads. Cannot be set together with other matchers.",
127+
},
128+
"gvk": {
129+
Type: schema.TypeList,
130+
Optional: true,
131+
Description: `Group, version, and kind for Kubernetes resources. Format: kind[.version][.group].
132+
It can be either:
133+
- only kind, e.g. "Deployment"
134+
- group and kind: e.g."Deployment.apps"
135+
- group, version and kind: e.g."Deployment.v1.apps"`,
136+
137+
Elem: &schema.Schema{Type: schema.TypeString},
138+
},
139+
"labels_expressions": k8sLabelExpressionsSchema(),
140+
},
141+
},
142+
},
143+
},
144+
},
145+
},
146+
},
147+
},
148+
},
75149
"name": {
76150
Type: schema.TypeString,
77151
Required: true,
@@ -200,6 +274,37 @@ func resourceWorkloadScalingPolicy() *schema.Resource {
200274
}
201275
}
202276

277+
func k8sLabelExpressionsSchema() *schema.Schema {
278+
return &schema.Schema{
279+
Type: schema.TypeList,
280+
Optional: true,
281+
Description: "Defines matching by label selector requirements.",
282+
Elem: &schema.Resource{
283+
Schema: map[string]*schema.Schema{
284+
"key": {
285+
Type: schema.TypeString,
286+
Required: true,
287+
Description: "The label key to match.",
288+
},
289+
"operator": {
290+
Type: schema.TypeString,
291+
Required: true,
292+
Description: "The operator to use for matching the label.",
293+
ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{
294+
K8sLabelInOperator, K8sLabelNotInOperator, K8sLabelExistsOperator, K8sLabelDoesNotExistOperator,
295+
}, false)),
296+
},
297+
"values": {
298+
Type: schema.TypeList,
299+
Optional: true,
300+
Description: "A list of values to match against the label key. Allowed for `In` and `NotIn` operators.",
301+
Elem: &schema.Schema{Type: schema.TypeString},
302+
},
303+
},
304+
},
305+
}
306+
}
307+
203308
func workloadScalingPolicyResourceSchema(resource, function string, overhead, minRecommended float64) *schema.Resource {
204309
return &schema.Resource{
205310
Schema: map[string]*schema.Schema{
@@ -414,6 +519,12 @@ func resourceWorkloadScalingPolicyCreate(ctx context.Context, d *schema.Resource
414519

415520
req.RecommendationPolicies.AntiAffinity = toAntiAffinity(toSection(d, "anti_affinity"))
416521

522+
ar, err := toAssignmentRules(toSection(d, "assignment_rules"))
523+
if err != nil {
524+
return diag.FromErr(err)
525+
}
526+
req.AssignmentRules = ar
527+
417528
create, err := client.WorkloadOptimizationAPICreateWorkloadScalingPolicyWithResponse(ctx, clusterID, req)
418529
if err != nil {
419530
return diag.FromErr(err)
@@ -489,6 +600,10 @@ func resourceWorkloadScalingPolicyRead(ctx context.Context, d *schema.ResourceDa
489600
return diag.FromErr(fmt.Errorf("setting anti-affinity: %w", err))
490601
}
491602

603+
if err := d.Set("assignment_rules", toAssignmentRulesMap(sp.AssignmentRules)); err != nil {
604+
return diag.FromErr(fmt.Errorf("setting assignment rules: %w", err))
605+
}
606+
492607
return nil
493608
}
494609

@@ -511,6 +626,7 @@ func resourceWorkloadScalingPolicyUpdate(ctx context.Context, d *schema.Resource
511626
"memory_event",
512627
"anti_affinity",
513628
FieldConfidence,
629+
"assignment_rules",
514630
) {
515631
tflog.Info(ctx, "scaling policy up to date")
516632
return nil
@@ -526,9 +642,15 @@ func resourceWorkloadScalingPolicyUpdate(ctx context.Context, d *schema.Resource
526642
if err != nil {
527643
return diag.FromErr(err)
528644
}
645+
ar, err := toAssignmentRules(toSection(d, "assignment_rules"))
646+
if err != nil {
647+
return diag.FromErr(err)
648+
}
649+
529650
req := sdk.WorkloadOptimizationAPIUpdateWorkloadScalingPolicyJSONRequestBody{
530-
Name: d.Get("name").(string),
531-
ApplyType: sdk.WorkloadoptimizationV1ApplyType(d.Get("apply_type").(string)),
651+
Name: d.Get("name").(string),
652+
ApplyType: sdk.WorkloadoptimizationV1ApplyType(d.Get("apply_type").(string)),
653+
AssignmentRules: ar,
532654
RecommendationPolicies: sdk.WorkloadoptimizationV1RecommendationPolicies{
533655
ManagementOption: sdk.WorkloadoptimizationV1ManagementOption(d.Get("management_option").(string)),
534656
Cpu: cpu,
@@ -1058,3 +1180,222 @@ func getWorkloadScalingPolicyByName(ctx context.Context, client sdk.ClientWithRe
10581180
}
10591181
return nil, fmt.Errorf("policy with name %q not found", name)
10601182
}
1183+
1184+
func toAssignmentRules(in map[string]any) (*[]sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule, error) {
1185+
if in == nil || len(in) == 0 {
1186+
return nil, nil
1187+
}
1188+
1189+
rules := in["rules"].([]any)
1190+
if len(rules) == 0 {
1191+
return &[]sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule{}, nil
1192+
}
1193+
1194+
result := make([]sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule, len(rules))
1195+
for i, rule := range rules {
1196+
ruleMap := rule.(map[string]any)
1197+
1198+
nsRules, err := toNamespaceAssignmentRule(ruleMap)
1199+
if err != nil {
1200+
return nil, fmt.Errorf("field assignment_rules[%d].namespace: %w", i, err)
1201+
}
1202+
1203+
wRules, err := toWorkloadAssignmentRule(ruleMap)
1204+
if err != nil {
1205+
return nil, fmt.Errorf("field assignment_rules[%d].workload: %w", i, err)
1206+
}
1207+
if nsRules == nil && wRules == nil {
1208+
continue
1209+
}
1210+
result[i] = sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule{
1211+
Workload: wRules,
1212+
Namespace: nsRules,
1213+
}
1214+
}
1215+
1216+
return &result, nil
1217+
}
1218+
1219+
func toNamespaceAssignmentRule(ruleMap map[string]any) (*sdk.WorkloadoptimizationV1KubernetesNamespaceMatcher, error) {
1220+
namespaceMap := getFirstElem(ruleMap, "namespace")
1221+
if namespaceMap == nil {
1222+
return nil, nil
1223+
}
1224+
1225+
namespaceMatcher := &sdk.WorkloadoptimizationV1KubernetesNamespaceMatcher{}
1226+
1227+
if all := readOptionalValue[bool](namespaceMap, "all"); all != nil && *all { //TODO: need to fix backend API to allow 'false'
1228+
namespaceMatcher.All = all
1229+
}
1230+
1231+
if names := readOptionalValue[[]any](namespaceMap, "names"); names != nil {
1232+
namespaceMatcher.Names = lo.ToPtr(toStringList(*names))
1233+
}
1234+
1235+
labels, err := toKubernetesLabelExpressionMatcher(namespaceMap)
1236+
if err != nil {
1237+
return nil, err
1238+
}
1239+
namespaceMatcher.LabelsExpressions = labels
1240+
1241+
return namespaceMatcher, nil
1242+
}
1243+
1244+
func toKubernetesLabelExpressionMatcher(namespaceMap map[string]any) (*[]sdk.WorkloadoptimizationV1KubernetesLabelExpressionMatcher, error) {
1245+
exprs := readOptionalValue[[]any](namespaceMap, "labels_expressions")
1246+
if exprs == nil {
1247+
return nil, nil
1248+
}
1249+
expressions := make([]sdk.WorkloadoptimizationV1KubernetesLabelExpressionMatcher, len(*exprs))
1250+
for j, expr := range *exprs {
1251+
exprMap := expr.(map[string]any)
1252+
key, err := mustGetValue[string](exprMap, "key")
1253+
if err != nil {
1254+
return nil, err
1255+
}
1256+
1257+
operator, err := mustGetValue[string](exprMap, "operator")
1258+
if err != nil {
1259+
return nil, err
1260+
}
1261+
1262+
expressions[j] = sdk.WorkloadoptimizationV1KubernetesLabelExpressionMatcher{
1263+
Key: *key,
1264+
Operator: toLabelSelectorOperator(*operator),
1265+
}
1266+
1267+
values := readOptionalValue[[]any](exprMap, "values")
1268+
if values != nil {
1269+
expressions[j].Values = toStringList(*values)
1270+
}
1271+
}
1272+
return &expressions, nil
1273+
}
1274+
1275+
func toWorkloadAssignmentRule(ruleMap map[string]any) (*sdk.WorkloadoptimizationV1KubernetesWorkloadMatcher, error) {
1276+
workloadMap := getFirstElem(ruleMap, "workload")
1277+
if workloadMap == nil {
1278+
return nil, nil
1279+
}
1280+
1281+
workloadMatcher := &sdk.WorkloadoptimizationV1KubernetesWorkloadMatcher{}
1282+
1283+
if all := readOptionalValue[bool](workloadMap, "all"); all != nil && *all { //TODO: need to fix backend API to allow 'false'
1284+
workloadMatcher.All = all
1285+
}
1286+
1287+
if gvk := readOptionalValue[[]any](workloadMap, "gvk"); gvk != nil {
1288+
workloadMatcher.Gvk = lo.ToPtr(toStringList(*gvk))
1289+
}
1290+
1291+
labels, err := toKubernetesLabelExpressionMatcher(workloadMap)
1292+
if err != nil {
1293+
return nil, err
1294+
}
1295+
workloadMatcher.LabelsExpressions = labels
1296+
1297+
return workloadMatcher, nil
1298+
}
1299+
1300+
func getFirstElem(in map[string]any, key string) map[string]any {
1301+
val, ok := in[key]
1302+
if !ok || val == nil || len(val.([]any)) == 0 || val.([]any)[0] == nil {
1303+
return nil
1304+
}
1305+
return val.([]any)[0].(map[string]any)
1306+
}
1307+
1308+
func toLabelSelectorOperator(in string) sdk.WorkloadoptimizationV1KubernetesLabelSelectorOperator {
1309+
switch in {
1310+
case K8sLabelInOperator:
1311+
return sdk.KUBERNETESLABELSELECTOROPIN
1312+
case K8sLabelNotInOperator:
1313+
return sdk.KUBERNETESLABELSELECTOROPNOTIN
1314+
case K8sLabelExistsOperator:
1315+
return sdk.KUBERNETESLABELSELECTOROPEXISTS
1316+
case K8sLabelDoesNotExistOperator:
1317+
return sdk.KUBERNETESLABELSELECTOROPDOESNOTEXIST
1318+
}
1319+
return sdk.KUBERNETESLABELSELECTOROPUNSPECIFIED
1320+
}
1321+
1322+
func toAssignmentRulesMap(rules *[]sdk.WorkloadoptimizationV1ScalingPolicyAssignmentRule) []any {
1323+
if rules == nil {
1324+
return nil
1325+
}
1326+
1327+
result := make([]map[string]any, len(*rules))
1328+
for i, rule := range *rules {
1329+
ruleMap := make(map[string]any)
1330+
1331+
if rule.Namespace != nil {
1332+
namespaceMap := make(map[string]any)
1333+
1334+
if rule.Namespace.All != nil {
1335+
namespaceMap["all"] = *rule.Namespace.All
1336+
}
1337+
1338+
if rule.Namespace.Names != nil {
1339+
namespaceMap["names"] = *rule.Namespace.Names
1340+
}
1341+
1342+
if rule.Namespace.LabelsExpressions != nil && len(*rule.Namespace.LabelsExpressions) > 0 {
1343+
namespaceMap["labels_expressions"] = toK8sLabelsExpressionsMap(*rule.Namespace.LabelsExpressions)
1344+
}
1345+
1346+
ruleMap["namespace"] = []map[string]any{namespaceMap}
1347+
}
1348+
1349+
if rule.Workload != nil {
1350+
workloadMap := make(map[string]any)
1351+
1352+
if rule.Workload.All != nil {
1353+
workloadMap["all"] = *rule.Workload.All
1354+
}
1355+
1356+
if rule.Workload.Gvk != nil && len(*rule.Workload.Gvk) > 0 {
1357+
workloadMap["gvk"] = *rule.Workload.Gvk
1358+
}
1359+
1360+
if rule.Workload.LabelsExpressions != nil && len(*rule.Workload.LabelsExpressions) > 0 {
1361+
workloadMap["labels_expressions"] = toK8sLabelsExpressionsMap(*rule.Workload.LabelsExpressions)
1362+
}
1363+
1364+
ruleMap["workload"] = []map[string]any{workloadMap}
1365+
}
1366+
1367+
result[i] = ruleMap
1368+
}
1369+
1370+
return []any{
1371+
map[string]any{
1372+
"rules": result,
1373+
},
1374+
}
1375+
}
1376+
1377+
func toK8sLabelsExpressionsMap(in []sdk.WorkloadoptimizationV1KubernetesLabelExpressionMatcher) []map[string]any {
1378+
expressions := make([]map[string]any, len(in))
1379+
for j, expr := range in {
1380+
expressions[j] = map[string]any{
1381+
"key": expr.Key,
1382+
"operator": labelSelectorOperatorMap(expr.Operator),
1383+
"values": expr.Values,
1384+
}
1385+
}
1386+
return expressions
1387+
}
1388+
1389+
func labelSelectorOperatorMap(in sdk.WorkloadoptimizationV1KubernetesLabelSelectorOperator) string {
1390+
switch in {
1391+
case sdk.KUBERNETESLABELSELECTOROPIN:
1392+
return K8sLabelInOperator
1393+
case sdk.KUBERNETESLABELSELECTOROPNOTIN:
1394+
return K8sLabelNotInOperator
1395+
case sdk.KUBERNETESLABELSELECTOROPEXISTS:
1396+
return K8sLabelExistsOperator
1397+
case sdk.KUBERNETESLABELSELECTOROPDOESNOTEXIST:
1398+
return K8sLabelDoesNotExistOperator
1399+
}
1400+
return "unspecified"
1401+
}

0 commit comments

Comments
 (0)