Skip to content

Commit 3832722

Browse files
committed
Add HardwareAffinity field to TinkerbellMachineConfig API
This adds a new HardwareAffinity field that provides advanced hardware selection using Kubernetes-style label selectors with required and preferred affinity terms. Key changes: - Add HardwareAffinity, HardwareAffinityTerm, WeightedHardwareAffinityTerm types - Support required terms (OR'd together) and preferred terms (weighted scoring) - Support matchLabels and matchExpressions in label selectors - Add validation for mutual exclusivity with HardwareSelector - Add validation for weight range (1-100) and operator types - Make HardwareSelector and HardwareAffinity mutable (changes affect new machines only) - Update template generation for control plane, worker, and etcd nodes - Update hardware validation assertions and reconciler The HardwareAffinity field is mutually exclusive with the existing HardwareSelector. Both fields are now mutable - changes only affect new machine provisioning as existing machines keep their assigned hardware via CAPT's ownership labels.
1 parent dc0984a commit 3832722

16 files changed

+1367
-95
lines changed

docs/content/en/docs/getting-started/baremetal/bare-spec.md

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,8 @@ In the example, there are `TinkerbellMachineConfig` sections for control plane (
342342
The following fields identify information needed to configure the nodes in each of those groups.
343343
>**_NOTE:_** Currently, you can only have one machine group for all machines in the control plane, although you can have multiple machine groups for the workers.
344344
>
345-
### hardwareSelector (required)
346-
Use fields under `hardwareSelector` to add key/value pair labels to match particular machines that you identified in the CSV file where you defined the machines in your cluster.
345+
### hardwareSelector (optional)
346+
Use `hardwareSelector` to add key/value pair labels to match particular machines that you identified in the CSV file where you defined the machines in your cluster.
347347
Choose any label name you like.
348348
For example, if you had added the label `node=cp-machine` to the machines listed in your CSV file that you want to be control plane nodes, the following `hardwareSelector` field would cause those machines to be added to the control plane:
349349
```yaml
@@ -356,6 +356,110 @@ spec:
356356
hardwareSelector:
357357
node: "cp-machine"
358358
```
359+
360+
>**_NOTE:_** Either `hardwareSelector` or `hardwareAffinity` must be specified, but not both. Use `hardwareSelector` for simple single-label matching, or `hardwareAffinity` for advanced selection with multiple terms and weighted preferences.
361+
362+
### hardwareAffinity (optional)
363+
Use `hardwareAffinity` for advanced hardware selection when you need more control than `hardwareSelector` provides. This field allows you to specify required and preferred affinity terms using Kubernetes-style label selectors.
364+
365+
The `hardwareAffinity` field has two sub-fields:
366+
- `required`: A list of hardware affinity terms that are OR'd together. Hardware must match at least one term to be considered. At least one required term must be specified.
367+
- `preferred`: A list of weighted hardware affinity terms. Hardware matching these terms are preferred according to the weights provided (1-100), but are not required.
368+
369+
#### hardwareAffinity.required
370+
Required hardware affinity terms. Each term contains a `labelSelector` with `matchLabels` and/or `matchExpressions`. Multiple terms in the `required` array are implicitly OR'd together - hardware must match at least one term to be eligible for selection.
371+
372+
#### hardwareAffinity.preferred
373+
Preferred hardware affinity terms with weights. Each term contains:
374+
- `weight`: A value from 1-100 indicating preference strength
375+
- `hardwareAffinityTerm`: The affinity term with a `labelSelector`
376+
377+
#### Example: Simple required affinity
378+
```yaml
379+
---
380+
apiVersion: anywhere.eks.amazonaws.com/v1alpha1
381+
kind: TinkerbellMachineConfig
382+
metadata:
383+
name: my-cluster-name-cp
384+
spec:
385+
hardwareAffinity:
386+
required:
387+
- labelSelector:
388+
matchLabels:
389+
node: "cp-machine"
390+
osFamily: ubuntu
391+
```
392+
393+
#### Example: Multiple required terms (OR'd together)
394+
When you specify multiple items in the `required` array, they are implicitly OR'd together. Hardware must match at least one of the terms to be eligible. In this example, hardware in either `rack-1` OR `rack-2` will be selected:
395+
```yaml
396+
---
397+
apiVersion: anywhere.eks.amazonaws.com/v1alpha1
398+
kind: TinkerbellMachineConfig
399+
metadata:
400+
name: my-cluster-name-cp
401+
spec:
402+
hardwareAffinity:
403+
required:
404+
- labelSelector:
405+
matchLabels:
406+
rack: "rack-1"
407+
- labelSelector:
408+
matchLabels:
409+
rack: "rack-2"
410+
osFamily: ubuntu
411+
```
412+
413+
#### Example: Required with preferred terms
414+
```yaml
415+
---
416+
apiVersion: anywhere.eks.amazonaws.com/v1alpha1
417+
kind: TinkerbellMachineConfig
418+
metadata:
419+
name: my-cluster-name-workers
420+
spec:
421+
hardwareAffinity:
422+
required:
423+
- labelSelector:
424+
matchLabels:
425+
role: "worker"
426+
preferred:
427+
- weight: 100
428+
hardwareAffinityTerm:
429+
labelSelector:
430+
matchLabels:
431+
gpu: "true"
432+
- weight: 50
433+
hardwareAffinityTerm:
434+
labelSelector:
435+
matchLabels:
436+
ssd: "true"
437+
osFamily: ubuntu
438+
```
439+
440+
#### Example: Using matchExpressions for complex selection
441+
```yaml
442+
---
443+
apiVersion: anywhere.eks.amazonaws.com/v1alpha1
444+
kind: TinkerbellMachineConfig
445+
metadata:
446+
name: my-cluster-name-cp
447+
spec:
448+
hardwareAffinity:
449+
required:
450+
- labelSelector:
451+
matchExpressions:
452+
- key: rack
453+
operator: In
454+
values:
455+
- rack-1
456+
- rack-2
457+
- rack-3
458+
osFamily: ubuntu
459+
```
460+
461+
>**_NOTE:_** Either `hardwareSelector` or `hardwareAffinity` must be specified, but not both. Use `hardwareSelector` for simple single-label matching, or `hardwareAffinity` for advanced selection with multiple terms and weighted preferences.
462+
359463
### osFamily (required)
360464
Operating system on the machine. Permitted values: `ubuntu` and `redhat` (Default: `ubuntu`).
361465

docs/content/en/docs/getting-started/baremetal/baremetal-getstarted.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ Follow these steps if you want to use your initial cluster to create and manage
213213
> ```
214214
> * For creating multiple workload clusters, it is essential that the hardware labels and selectors defined for a given workload cluster are unique to that workload cluster. For instance, for an EKS Anywhere cluster named `eksa-workload1`, the hardware that is assigned for this cluster should have labels that are only going to be used for this cluster like `type=eksa-workload1-cp` and `type=eksa-workload1-worker`.
215215
Another workload cluster named `eksa-workload2` can have labels like `type=eksa-workload2-cp` and `type=eksa-workload2-worker`. Please note that even though labels can be arbitrary, they need to be unique for each workload cluster. Not specifying unique cluster labels can cause cluster creations to behave in unexpected ways which may lead to unsuccessful creations and unstable clusters.
216-
See the [hardware selectors]({{< relref "./bare-spec/#hardwareselector-required" >}}) section for more information
216+
See the [hardware selectors]({{< relref "./bare-spec/#hardwareselector-optional" >}}) section for more information
217217
218218
1. Check the workload cluster:
219219

pkg/api/v1alpha1/tinkerbellmachineconfig.go

Lines changed: 134 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,9 @@ func validateTinkerbellMachineConfig(config *TinkerbellMachineConfig) error {
7070
return fmt.Errorf("TinkerbellMachineConfig: %v", err)
7171
}
7272

73-
if len(config.Spec.HardwareSelector) == 0 {
74-
return fmt.Errorf("TinkerbellMachineConfig: missing spec.hardwareSelector: %s", config.Name)
75-
}
76-
77-
if len(config.Spec.HardwareSelector) != 1 {
78-
return fmt.Errorf(
79-
"TinkerbellMachineConfig: spec.hardwareSelector must contain only 1 key-value pair: %s",
80-
config.Name,
81-
)
73+
// Validate hardware selection (HardwareSelector vs HardwareAffinity)
74+
if err := validateHardwareSelection(config); err != nil {
75+
return err
8276
}
8377

8478
if config.Spec.OSFamily == "" {
@@ -112,6 +106,137 @@ func validateTinkerbellMachineConfig(config *TinkerbellMachineConfig) error {
112106
return nil
113107
}
114108

109+
// validateHardwareSelection validates the hardware selection configuration.
110+
// HardwareSelector and HardwareAffinity are mutually exclusive.
111+
func validateHardwareSelection(config *TinkerbellMachineConfig) error {
112+
hasSelector := !config.Spec.HardwareSelector.IsEmpty()
113+
hasAffinity := config.Spec.HardwareAffinity != nil
114+
115+
// Check mutual exclusivity
116+
if hasSelector && hasAffinity {
117+
return fmt.Errorf("TinkerbellMachineConfig: hardwareSelector and hardwareAffinity are mutually exclusive: %s", config.Name)
118+
}
119+
120+
// At least one must be specified
121+
if !hasSelector && !hasAffinity {
122+
return fmt.Errorf("TinkerbellMachineConfig: either hardwareSelector or hardwareAffinity must be specified: %s", config.Name)
123+
}
124+
125+
// Validate HardwareSelector if present
126+
if hasSelector {
127+
if len(config.Spec.HardwareSelector) != 1 {
128+
return fmt.Errorf(
129+
"TinkerbellMachineConfig: spec.hardwareSelector must contain only 1 key-value pair: %s",
130+
config.Name,
131+
)
132+
}
133+
return nil
134+
}
135+
136+
// Validate HardwareAffinity if present
137+
return validateHardwareAffinity(config.Spec.HardwareAffinity, config.Name)
138+
}
139+
140+
// validateHardwareAffinity validates the HardwareAffinity configuration.
141+
func validateHardwareAffinity(affinity *HardwareAffinity, configName string) error {
142+
// Required terms must have at least one entry
143+
if len(affinity.Required) == 0 {
144+
return fmt.Errorf("TinkerbellMachineConfig: hardwareAffinity.required must have at least one term: %s", configName)
145+
}
146+
147+
// Validate each required term
148+
for i, term := range affinity.Required {
149+
if err := validateLabelSelector(&term.LabelSelector, configName, fmt.Sprintf("required[%d]", i)); err != nil {
150+
return err
151+
}
152+
}
153+
154+
// Validate each preferred term
155+
for i, weightedTerm := range affinity.Preferred {
156+
// Validate weight range (1-100)
157+
if weightedTerm.Weight < 1 || weightedTerm.Weight > 100 {
158+
return fmt.Errorf("TinkerbellMachineConfig: preferred term weight must be in range [1, 100], got %d: %s", weightedTerm.Weight, configName)
159+
}
160+
161+
if err := validateLabelSelector(&weightedTerm.HardwareAffinityTerm.LabelSelector, configName, fmt.Sprintf("preferred[%d]", i)); err != nil {
162+
return err
163+
}
164+
}
165+
166+
return nil
167+
}
168+
169+
// validateLabelSelector validates a Kubernetes LabelSelector.
170+
func validateLabelSelector(selector *metav1.LabelSelector, configName, path string) error {
171+
// Validate matchExpressions
172+
for j, expr := range selector.MatchExpressions {
173+
// Validate operator
174+
if !isValidLabelSelectorOperator(expr.Operator) {
175+
return fmt.Errorf("TinkerbellMachineConfig: invalid matchExpression operator '%s' in %s.labelSelector.matchExpressions[%d], must be one of: In, NotIn, Exists, DoesNotExist: %s",
176+
expr.Operator, path, j, configName)
177+
}
178+
179+
// Validate values for In/NotIn operators
180+
if expr.Operator == metav1.LabelSelectorOpIn || expr.Operator == metav1.LabelSelectorOpNotIn {
181+
if len(expr.Values) == 0 {
182+
return fmt.Errorf("TinkerbellMachineConfig: matchExpression with operator %s must have non-empty values in %s.labelSelector.matchExpressions[%d]: %s",
183+
expr.Operator, path, j, configName)
184+
}
185+
}
186+
187+
// Validate that Exists/DoesNotExist don't have values
188+
if expr.Operator == metav1.LabelSelectorOpExists || expr.Operator == metav1.LabelSelectorOpDoesNotExist {
189+
if len(expr.Values) > 0 {
190+
return fmt.Errorf("TinkerbellMachineConfig: matchExpression with operator %s must not have values in %s.labelSelector.matchExpressions[%d]: %s",
191+
expr.Operator, path, j, configName)
192+
}
193+
}
194+
}
195+
196+
return nil
197+
}
198+
199+
// isValidLabelSelectorOperator checks if the operator is a valid LabelSelector operator.
200+
func isValidLabelSelectorOperator(op metav1.LabelSelectorOperator) bool {
201+
switch op {
202+
case metav1.LabelSelectorOpIn, metav1.LabelSelectorOpNotIn,
203+
metav1.LabelSelectorOpExists, metav1.LabelSelectorOpDoesNotExist:
204+
return true
205+
default:
206+
return false
207+
}
208+
}
209+
210+
// ValidateHardwareAffinityOperator validates a single operator string.
211+
func ValidateHardwareAffinityOperator(op string) bool {
212+
return isValidLabelSelectorOperator(metav1.LabelSelectorOperator(op))
213+
}
214+
215+
// ValidateHardwareAffinityWeight validates a weight value.
216+
func ValidateHardwareAffinityWeight(weight int32) bool {
217+
return weight >= 1 && weight <= 100
218+
}
219+
220+
// ValidateLabelSelectorRequirement validates a single LabelSelectorRequirement.
221+
func ValidateLabelSelectorRequirement(req metav1.LabelSelectorRequirement) error {
222+
if !isValidLabelSelectorOperator(req.Operator) {
223+
return fmt.Errorf("invalid operator '%s'", req.Operator)
224+
}
225+
226+
switch req.Operator {
227+
case metav1.LabelSelectorOpIn, metav1.LabelSelectorOpNotIn:
228+
if len(req.Values) == 0 {
229+
return fmt.Errorf("operator %s requires non-empty values", req.Operator)
230+
}
231+
case metav1.LabelSelectorOpExists, metav1.LabelSelectorOpDoesNotExist:
232+
if len(req.Values) > 0 {
233+
return fmt.Errorf("operator %s must not have values", req.Operator)
234+
}
235+
}
236+
237+
return nil
238+
}
239+
115240
func setTinkerbellMachineConfigDefaults(machineConfig *TinkerbellMachineConfig) {
116241
if machineConfig.Spec.OSFamily == "" {
117242
machineConfig.Spec.OSFamily = Bottlerocket

pkg/api/v1alpha1/tinkerbellmachineconfig_types.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,18 @@ import (
1010

1111
// TinkerbellMachineConfigSpec defines the desired state of TinkerbellMachineConfig.
1212
type TinkerbellMachineConfigSpec struct {
13-
HardwareSelector HardwareSelector `json:"hardwareSelector"`
14-
TemplateRef Ref `json:"templateRef,omitempty"`
15-
OSFamily OSFamily `json:"osFamily"`
13+
// HardwareSelector is a simple key-value selector for hardware.
14+
// Use this for straightforward single-label hardware selection.
15+
// Mutually exclusive with HardwareAffinity.
16+
// +optional
17+
HardwareSelector HardwareSelector `json:"hardwareSelector,omitempty"`
18+
19+
// HardwareAffinity allows advanced hardware selection using required
20+
// and preferred affinity terms. Mutually exclusive with HardwareSelector.
21+
// +optional
22+
HardwareAffinity *HardwareAffinity `json:"hardwareAffinity,omitempty"`
23+
TemplateRef Ref `json:"templateRef,omitempty"`
24+
OSFamily OSFamily `json:"osFamily"`
1625
//+optional
1726
// OSImageURL can be used to override the default OS image path to pull from a local server.
1827
// OSImageURL is a URL to the OS image used during provisioning. It must include
@@ -39,6 +48,36 @@ func (s HardwareSelector) ToString() (string, error) {
3948
return string(encoded), nil
4049
}
4150

51+
// HardwareAffinity defines required and preferred hardware affinities.
52+
type HardwareAffinity struct {
53+
// Required are the required hardware affinity terms. The terms are OR'd
54+
// together - hardware must match at least one term to be considered.
55+
// At least one required term must be specified.
56+
Required []HardwareAffinityTerm `json:"required"`
57+
58+
// Preferred are the preferred hardware affinity terms. Hardware matching
59+
// these terms are preferred according to the weights provided.
60+
// +optional
61+
Preferred []WeightedHardwareAffinityTerm `json:"preferred,omitempty"`
62+
}
63+
64+
// HardwareAffinityTerm defines a single hardware affinity term.
65+
type HardwareAffinityTerm struct {
66+
// LabelSelector is used to select hardware by labels.
67+
LabelSelector metav1.LabelSelector `json:"labelSelector"`
68+
}
69+
70+
// WeightedHardwareAffinityTerm is a HardwareAffinityTerm with an associated weight.
71+
type WeightedHardwareAffinityTerm struct {
72+
// Weight associated with matching the corresponding term, in range 1-100.
73+
// +kubebuilder:validation:Minimum=1
74+
// +kubebuilder:validation:Maximum=100
75+
Weight int32 `json:"weight"`
76+
77+
// HardwareAffinityTerm is the term associated with the weight.
78+
HardwareAffinityTerm HardwareAffinityTerm `json:"hardwareAffinityTerm"`
79+
}
80+
4281
func (c *TinkerbellMachineConfig) PauseReconcile() {
4382
c.Annotations[pausedAnnotation] = "true"
4483
}

0 commit comments

Comments
 (0)