Skip to content

Commit 5acede4

Browse files
committed
Allocate dedicated host when a dedicated host doesn't exist
1 parent 15a2d14 commit 5acede4

28 files changed

+1234
-39
lines changed

api/v1beta1/awscluster_conversion.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ func (src *AWSCluster) ConvertTo(dstRaw conversion.Hub) error {
6767
dst.Status.Bastion.HostID = restored.Status.Bastion.HostID
6868
dst.Status.Bastion.CapacityReservationPreference = restored.Status.Bastion.CapacityReservationPreference
6969
dst.Status.Bastion.CPUOptions = restored.Status.Bastion.CPUOptions
70+
if restored.Status.Bastion.DynamicHostAllocation != nil {
71+
dst.Status.Bastion.DynamicHostAllocation = restored.Status.Bastion.DynamicHostAllocation
72+
}
7073
}
7174
dst.Spec.Partition = restored.Spec.Partition
7275

api/v1beta1/awsmachine_conversion.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ func (src *AWSMachine) ConvertTo(dstRaw conversion.Hub) error {
4949
dst.Spec.CapacityReservationPreference = restored.Spec.CapacityReservationPreference
5050
dst.Spec.NetworkInterfaceType = restored.Spec.NetworkInterfaceType
5151
dst.Spec.CPUOptions = restored.Spec.CPUOptions
52+
if restored.Spec.DynamicHostAllocation != nil {
53+
dst.Spec.DynamicHostAllocation = restored.Spec.DynamicHostAllocation
54+
}
5255
if restored.Spec.ElasticIPPool != nil {
5356
if dst.Spec.ElasticIPPool == nil {
5457
dst.Spec.ElasticIPPool = &infrav1.ElasticIPPool{}
@@ -61,6 +64,7 @@ func (src *AWSMachine) ConvertTo(dstRaw conversion.Hub) error {
6164
}
6265
}
6366

67+
dst.Status.DedicatedHost = restored.Status.DedicatedHost
6468
return nil
6569
}
6670

@@ -117,6 +121,9 @@ func (r *AWSMachineTemplate) ConvertTo(dstRaw conversion.Hub) error {
117121
dst.Spec.Template.Spec.CapacityReservationPreference = restored.Spec.Template.Spec.CapacityReservationPreference
118122
dst.Spec.Template.Spec.NetworkInterfaceType = restored.Spec.Template.Spec.NetworkInterfaceType
119123
dst.Spec.Template.Spec.CPUOptions = restored.Spec.Template.Spec.CPUOptions
124+
if restored.Spec.Template.Spec.DynamicHostAllocation != nil {
125+
dst.Spec.Template.Spec.DynamicHostAllocation = restored.Spec.Template.Spec.DynamicHostAllocation
126+
}
120127
if restored.Spec.Template.Spec.ElasticIPPool != nil {
121128
if dst.Spec.Template.Spec.ElasticIPPool == nil {
122129
dst.Spec.Template.Spec.ElasticIPPool = &infrav1.ElasticIPPool{}

api/v1beta1/conversion.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,8 @@ func Convert_v1beta2_S3Bucket_To_v1beta1_S3Bucket(in *v1beta2.S3Bucket, out *S3B
103103
func Convert_v1beta2_Ignition_To_v1beta1_Ignition(in *v1beta2.Ignition, out *Ignition, s conversion.Scope) error {
104104
return autoConvert_v1beta2_Ignition_To_v1beta1_Ignition(in, out, s)
105105
}
106+
107+
func Convert_v1beta2_AWSMachineStatus_To_v1beta1_AWSMachineStatus(in *v1beta2.AWSMachineStatus, out *AWSMachineStatus, s conversion.Scope) error {
108+
// Note: DedicatedHostID is not present in v1beta1, so it will be dropped during conversion
109+
return autoConvert_v1beta2_AWSMachineStatus_To_v1beta1_AWSMachineStatus(in, out, s)
110+
}

api/v1beta1/zz_generated.conversion.go

Lines changed: 3 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v1beta2/awsmachine_types.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ type AWSMachineSpec struct {
218218
PlacementGroupPartition int64 `json:"placementGroupPartition,omitempty"`
219219

220220
// Tenancy indicates if instance should run on shared or single-tenant hardware.
221+
// When Tenancy=host, AWS will attempt to find a suitable host from:
222+
// - Preexisting allocated hosts that have auto-placement enabled
223+
// - A specific host ID, if configured
224+
// - Allocating a new dedicated host if DynamicHostAllocation is configured
221225
// +optional
222226
// +kubebuilder:validation:Enum:=default;dedicated;host
223227
Tenancy string `json:"tenancy,omitempty"`
@@ -240,17 +244,28 @@ type AWSMachineSpec struct {
240244
MarketType MarketType `json:"marketType,omitempty"`
241245

242246
// HostID specifies the Dedicated Host on which the instance must be started.
247+
// This field is mutually exclusive with DynamicHostAllocation.
248+
// +kubebuilder:validation:Pattern=`^h-[0-9a-f]{17}$`
249+
// +kubebuilder:validation:MaxLength=19
243250
// +optional
244251
HostID *string `json:"hostID,omitempty"`
245252

246253
// HostAffinity specifies the dedicated host affinity setting for the instance.
247-
// When hostAffinity is set to host, an instance started onto a specific host always restarts on the same host if stopped.
248-
// When hostAffinity is set to default, and you stop and restart the instance, it can be restarted on any available host.
254+
// When HostAffinity is set to host, an instance started onto a specific host always restarts on the same host if stopped.
255+
// When HostAffinity is set to default, and you stop and restart the instance, it can be restarted on any available host.
249256
// When HostAffinity is defined, HostID is required.
250257
// +optional
251258
// +kubebuilder:validation:Enum:=default;host
259+
// +kubebuilder:default=host
252260
HostAffinity *string `json:"hostAffinity,omitempty"`
253261

262+
// DynamicHostAllocation enables automatic allocation of a single dedicated host.
263+
// This field is mutually exclusive with HostID and always allocates exactly one host.
264+
// Cost effectiveness of allocating a single instance on a dedicated host may vary
265+
// depending on the instance type and the region.
266+
// +optional
267+
DynamicHostAllocation *DynamicHostAllocationSpec `json:"dynamicHostAllocation,omitempty"`
268+
254269
// CapacityReservationPreference specifies the preference for use of Capacity Reservations by the instance. Valid values include:
255270
// "Open": The instance may make use of open Capacity Reservations that match its AZ and InstanceType
256271
// "None": The instance may not make use of any Capacity Reservations. This is to conserve open reservations for desired workloads
@@ -260,6 +275,14 @@ type AWSMachineSpec struct {
260275
CapacityReservationPreference CapacityReservationPreference `json:"capacityReservationPreference,omitempty"`
261276
}
262277

278+
// DynamicHostAllocationSpec defines the configuration for dynamic dedicated host allocation.
279+
// This specification always allocates exactly one dedicated host per machine.
280+
type DynamicHostAllocationSpec struct {
281+
// Tags to apply to the allocated dedicated host.
282+
// +optional
283+
Tags map[string]string `json:"tags,omitempty"`
284+
}
285+
263286
// CloudInit defines options related to the bootstrapping systems where
264287
// CloudInit is used.
265288
type CloudInit struct {
@@ -438,6 +461,21 @@ type AWSMachineStatus struct {
438461
// Conditions defines current service state of the AWSMachine.
439462
// +optional
440463
Conditions clusterv1.Conditions `json:"conditions,omitempty"`
464+
465+
// DedicatedHost tracks the dynamically allocated dedicated host.
466+
// This field is populated when DynamicHostAllocation is used.
467+
// +optional
468+
DedicatedHost *DedicatedHostStatus `json:"dedicatedHost,omitempty"`
469+
}
470+
471+
// DedicatedHostStatus defines the observed state of a dynamically allocated dedicated host
472+
// associated with an AWSMachine. This struct is used to track the ID of the dedicated host
473+
// and any failure messages encountered during host release operations.
474+
type DedicatedHostStatus struct {
475+
// ID tracks the dynamically allocated dedicated host ID.
476+
// This field is populated when DynamicHostAllocation is used.
477+
// +optional
478+
ID *string `json:"id,omitempty"`
441479
}
442480

443481
// +kubebuilder:object:root=true

api/v1beta2/awsmachine_webhook.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,11 @@ func (*awsMachineWebhook) ValidateCreate(_ context.Context, obj runtime.Object)
7575
allErrs = append(allErrs, r.validateNonRootVolumes()...)
7676
allErrs = append(allErrs, r.validateSSHKeyName()...)
7777
allErrs = append(allErrs, r.validateAdditionalSecurityGroups()...)
78-
allErrs = append(allErrs, r.validateHostAffinity()...)
7978
allErrs = append(allErrs, r.Spec.AdditionalTags.Validate()...)
8079
allErrs = append(allErrs, r.validateNetworkElasticIPPool()...)
8180
allErrs = append(allErrs, r.validateInstanceMarketType()...)
8281
allErrs = append(allErrs, r.validateCapacityReservation()...)
82+
allErrs = append(allErrs, r.validateHostAllocation()...)
8383

8484
return nil, aggregateObjErrors(r.GroupVersionKind().GroupKind(), r.Name, allErrs)
8585
}
@@ -109,7 +109,7 @@ func (*awsMachineWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj run
109109
allErrs = append(allErrs, r.validateCloudInitSecret()...)
110110
allErrs = append(allErrs, r.validateAdditionalSecurityGroups()...)
111111
allErrs = append(allErrs, r.Spec.AdditionalTags.Validate()...)
112-
allErrs = append(allErrs, r.validateHostAffinity()...)
112+
allErrs = append(allErrs, r.validateHostAllocation()...)
113113

114114
newAWSMachineSpec := newAWSMachine["spec"].(map[string]interface{})
115115
oldAWSMachineSpec := oldAWSMachine["spec"].(map[string]interface{})
@@ -474,14 +474,17 @@ func (r *AWSMachine) validateAdditionalSecurityGroups() field.ErrorList {
474474
return allErrs
475475
}
476476

477-
func (r *AWSMachine) validateHostAffinity() field.ErrorList {
477+
func (r *AWSMachine) validateHostAllocation() field.ErrorList {
478478
var allErrs field.ErrorList
479479

480-
if r.Spec.HostAffinity != nil {
481-
if r.Spec.HostID == nil || len(*r.Spec.HostID) == 0 {
482-
allErrs = append(allErrs, field.Required(field.NewPath("spec.hostID"), "hostID must be set when hostAffinity is configured"))
483-
}
480+
// Check if both hostID and dynamicHostAllocation are specified
481+
hasHostID := r.Spec.HostID != nil && len(*r.Spec.HostID) > 0
482+
hasDynamicHostAllocation := r.Spec.DynamicHostAllocation != nil
483+
484+
if hasHostID && hasDynamicHostAllocation {
485+
allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.hostID"), "hostID and dynamicHostAllocation are mutually exclusive"), field.Forbidden(field.NewPath("spec.dynamicHostAllocation"), "hostID and dynamicHostAllocation are mutually exclusive"))
484486
}
487+
485488
return allErrs
486489
}
487490

api/v1beta2/awsmachine_webhook_test.go

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -489,16 +489,6 @@ func TestAWSMachineCreate(t *testing.T) {
489489
},
490490
wantErr: true,
491491
},
492-
{
493-
name: "configure host affinity without Host ID",
494-
machine: &AWSMachine{
495-
Spec: AWSMachineSpec{
496-
InstanceType: "test",
497-
HostAffinity: ptr.To("default"),
498-
},
499-
},
500-
wantErr: true,
501-
},
502492
{
503493
name: "create with valid BYOIPv4",
504494
machine: &AWSMachine{
@@ -567,6 +557,45 @@ func TestAWSMachineCreate(t *testing.T) {
567557
},
568558
wantErr: true,
569559
},
560+
{
561+
name: "hostID and dynamicHostAllocation are mutually exclusive",
562+
machine: &AWSMachine{
563+
Spec: AWSMachineSpec{
564+
InstanceType: "test",
565+
HostID: aws.String("h-1234567890abcdef0"),
566+
DynamicHostAllocation: &DynamicHostAllocationSpec{
567+
Tags: map[string]string{
568+
"Environment": "test",
569+
},
570+
},
571+
},
572+
},
573+
wantErr: true,
574+
},
575+
{
576+
name: "hostID alone is valid",
577+
machine: &AWSMachine{
578+
Spec: AWSMachineSpec{
579+
InstanceType: "test",
580+
HostID: aws.String("h-1234567890abcdef0"),
581+
},
582+
},
583+
wantErr: false,
584+
},
585+
{
586+
name: "dynamicHostAllocation alone is valid",
587+
machine: &AWSMachine{
588+
Spec: AWSMachineSpec{
589+
InstanceType: "test",
590+
DynamicHostAllocation: &DynamicHostAllocationSpec{
591+
Tags: map[string]string{
592+
"Environment": "test",
593+
},
594+
},
595+
},
596+
},
597+
wantErr: false,
598+
},
570599
}
571600
for _, tt := range tests {
572601
t.Run(tt.name, func(t *testing.T) {

api/v1beta2/awsmachinetemplate_webhook.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,22 @@ func (r *AWSMachineTemplate) validateIgnitionAndCloudInit() field.ErrorList {
172172

173173
return allErrs
174174
}
175+
func (r *AWSMachineTemplate) validateHostAllocation() field.ErrorList {
176+
var allErrs field.ErrorList
177+
178+
spec := r.Spec.Template.Spec
179+
180+
// Check if both hostID and dynamicHostAllocation are specified
181+
hasHostID := spec.HostID != nil && len(*spec.HostID) > 0
182+
hasDynamicHostAllocation := spec.DynamicHostAllocation != nil
183+
184+
if hasHostID && hasDynamicHostAllocation {
185+
allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.template.spec.hostID"), "hostID and dynamicHostAllocation are mutually exclusive"), field.Forbidden(field.NewPath("spec.template.spec.dynamicHostAllocation"), "hostID and dynamicHostAllocation are mutually exclusive"))
186+
}
187+
188+
return allErrs
189+
}
190+
175191
func (r *AWSMachineTemplate) validateSSHKeyName() field.ErrorList {
176192
return validateSSHKeyName(r.Spec.Template.Spec.SSHKeyName)
177193
}
@@ -205,6 +221,7 @@ func (r *AWSMachineTemplateWebhook) ValidateCreate(_ context.Context, raw runtim
205221
allErrs = append(allErrs, obj.validateSSHKeyName()...)
206222
allErrs = append(allErrs, obj.validateAdditionalSecurityGroups()...)
207223
allErrs = append(allErrs, obj.Spec.Template.Spec.AdditionalTags.Validate()...)
224+
allErrs = append(allErrs, obj.validateHostAllocation()...)
208225

209226
return nil, aggregateObjErrors(obj.GroupVersionKind().GroupKind(), obj.Name, allErrs)
210227
}

api/v1beta2/awsmachinetemplate_webhook_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ func TestAWSMachineTemplateValidateCreate(t *testing.T) {
8080
},
8181
wantError: false,
8282
},
83+
{
84+
name: "hostID and dynamicHostAllocation are mutually exclusive",
85+
inputTemplate: &AWSMachineTemplate{
86+
ObjectMeta: metav1.ObjectMeta{},
87+
Spec: AWSMachineTemplateSpec{
88+
Template: AWSMachineTemplateResource{
89+
Spec: AWSMachineSpec{
90+
InstanceType: "test",
91+
HostID: aws.String("h-1234567890abcdef0"),
92+
DynamicHostAllocation: &DynamicHostAllocationSpec{
93+
Tags: map[string]string{
94+
"Environment": "test",
95+
},
96+
},
97+
},
98+
},
99+
},
100+
},
101+
wantError: true,
102+
},
83103
}
84104
for _, tt := range tests {
85105
t.Run(tt.name, func(t *testing.T) {

api/v1beta2/conditions_consts.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ const (
146146
// InstanceReadyCondition reports on current status of the EC2 instance. Ready indicates the instance is in a Running state.
147147
InstanceReadyCondition clusterv1.ConditionType = "InstanceReady"
148148

149+
// DedicatedHostReleaseCondition reports on the status of dedicated host release operations.
150+
// This condition tracks whether the dedicated host has been successfully released or if there are failures.
151+
DedicatedHostReleaseCondition clusterv1.ConditionType = "DedicatedHostRelease"
152+
149153
// InstanceNotFoundReason used when the instance couldn't be retrieved.
150154
InstanceNotFoundReason = "InstanceNotFound"
151155
// InstanceTerminatedReason instance is in a terminated state.
@@ -191,4 +195,7 @@ const (
191195

192196
// S3BucketFailedReason is used when any errors occur during reconciliation of an S3 bucket.
193197
S3BucketFailedReason = "S3BucketCreationFailed"
198+
199+
// DedicatedHostReleaseFailedReason used when the dedicated host release fails.
200+
DedicatedHostReleaseFailedReason = "DedicatedHostReleaseFailed"
194201
)

0 commit comments

Comments
 (0)