Skip to content

Commit e1dc9ae

Browse files
committed
feat: Add dynamic dedicated host allocation for CAPA provider
This commit implements dynamic dedicated host allocation capability to support bare metal workloads like OpenShift Virtualization with HyperShift on ROSA HCP, particularly for BYOL Microsoft Windows workloads. Key changes: - Add DynamicHostAllocationSpec API with instance family, type, quantity, AZ, and tagging support - Implement dedicated host service with allocation, release, and validation operations - Integrate dynamic allocation into machine lifecycle with automatic cleanup - Add comprehensive webhook validation for configuration consistency - Include unit tests and usage examples - Generate updated CRDs with new API fields The implementation follows existing CAPA patterns from PR kubernetes-sigs#5548 and provides automatic provisioning and management of AWS dedicated hosts based on instance requirements. Related: kubernetes-sigs#5548
1 parent 5f62ff6 commit e1dc9ae

22 files changed

+1842
-8
lines changed

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: AllocatedHostID 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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,45 @@ type AWSMachineSpec struct {
245245
// +optional
246246
// +kubebuilder:validation:Enum:=default;host
247247
HostAffinity *string `json:"hostAffinity,omitempty"`
248+
249+
// DynamicHostAllocation enables automatic allocation of dedicated hosts.
250+
// This field is mutually exclusive with HostID.
251+
// +optional
252+
DynamicHostAllocation *DynamicHostAllocationSpec `json:"dynamicHostAllocation,omitempty"`
253+
}
254+
255+
// DynamicHostAllocationSpec defines the configuration for dynamic dedicated host allocation.
256+
type DynamicHostAllocationSpec struct {
257+
// InstanceFamily specifies the EC2 instance family (e.g., "m5", "c5", "r5").
258+
// +kubebuilder:validation:Required
259+
InstanceFamily string `json:"instanceFamily"`
260+
261+
// AvailabilityZone specifies the target availability zone for allocation.
262+
// If not specified, uses the same AZ as the instance.
263+
// +optional
264+
AvailabilityZone *string `json:"availabilityZone,omitempty"`
265+
266+
// InstanceType specifies the specific instance type for the dedicated host.
267+
// If not specified, derives from InstanceFamily.
268+
// +optional
269+
InstanceType *string `json:"instanceType,omitempty"`
270+
271+
// Quantity specifies the number of dedicated hosts to allocate.
272+
// +kubebuilder:validation:Minimum=1
273+
// +kubebuilder:validation:Maximum=10
274+
// +kubebuilder:default=1
275+
// +optional
276+
Quantity *int32 `json:"quantity,omitempty"`
277+
278+
// AutoRelease determines whether to automatically release the dedicated host
279+
// when the machine is deleted.
280+
// +kubebuilder:default=true
281+
// +optional
282+
AutoRelease *bool `json:"autoRelease,omitempty"`
283+
284+
// Tags to apply to the allocated dedicated host.
285+
// +optional
286+
Tags map[string]string `json:"tags,omitempty"`
248287
}
249288

250289
// CloudInit defines options related to the bootstrapping systems where
@@ -424,6 +463,11 @@ type AWSMachineStatus struct {
424463
// Conditions defines current service state of the AWSMachine.
425464
// +optional
426465
Conditions clusterv1.Conditions `json:"conditions,omitempty"`
466+
467+
// AllocatedHostID tracks the dynamically allocated dedicated host ID.
468+
// This field is populated when DynamicHostAllocation is used.
469+
// +optional
470+
AllocatedHostID *string `json:"allocatedHostID,omitempty"`
427471
}
428472

429473
// +kubebuilder:object:root=true

api/v1beta2/awsmachine_webhook.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,14 +460,164 @@ func (r *AWSMachine) validateAdditionalSecurityGroups() field.ErrorList {
460460
func (r *AWSMachine) validateHostAffinity() field.ErrorList {
461461
var allErrs field.ErrorList
462462

463+
// Validate static host allocation
463464
if r.Spec.HostAffinity != nil {
464465
if r.Spec.HostID == nil || len(*r.Spec.HostID) == 0 {
465466
allErrs = append(allErrs, field.Required(field.NewPath("spec.hostID"), "hostID must be set when hostAffinity is configured"))
466467
}
467468
}
469+
470+
// Validate dynamic host allocation
471+
if r.Spec.DynamicHostAllocation != nil {
472+
// Mutual exclusivity check
473+
if r.Spec.HostID != nil {
474+
allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.hostID"), "cannot specify both hostID and dynamicHostAllocation"))
475+
}
476+
if r.Spec.HostAffinity != nil {
477+
allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.hostAffinity"), "cannot specify both hostAffinity and dynamicHostAllocation"))
478+
}
479+
480+
// Validate dynamic allocation spec
481+
allErrs = append(allErrs, r.validateDynamicHostAllocation()...)
482+
}
483+
484+
return allErrs
485+
}
486+
487+
func (r *AWSMachine) validateDynamicHostAllocation() field.ErrorList {
488+
var allErrs field.ErrorList
489+
spec := r.Spec.DynamicHostAllocation
490+
491+
// Validate instance family is required
492+
if spec.InstanceFamily == "" {
493+
allErrs = append(allErrs, field.Required(field.NewPath("spec.dynamicHostAllocation.instanceFamily"), "instanceFamily is required"))
494+
} else {
495+
// Validate instance family format
496+
if !isValidInstanceFamily(spec.InstanceFamily) {
497+
allErrs = append(allErrs, field.Invalid(field.NewPath("spec.dynamicHostAllocation.instanceFamily"), spec.InstanceFamily, "invalid instance family format"))
498+
}
499+
}
500+
501+
// Validate quantity if specified
502+
if spec.Quantity != nil {
503+
if *spec.Quantity < 1 || *spec.Quantity > 10 {
504+
allErrs = append(allErrs, field.Invalid(field.NewPath("spec.dynamicHostAllocation.quantity"), *spec.Quantity, "quantity must be between 1 and 10"))
505+
}
506+
}
507+
508+
// Validate instance type format if specified
509+
if spec.InstanceType != nil && *spec.InstanceType != "" {
510+
if !isValidInstanceType(*spec.InstanceType) {
511+
allErrs = append(allErrs, field.Invalid(field.NewPath("spec.dynamicHostAllocation.instanceType"), *spec.InstanceType, "invalid instance type format"))
512+
}
513+
514+
// Check consistency between instance family and instance type
515+
expectedFamily := extractInstanceFamily(*spec.InstanceType)
516+
if expectedFamily != spec.InstanceFamily {
517+
allErrs = append(allErrs, field.Invalid(field.NewPath("spec.dynamicHostAllocation.instanceType"), *spec.InstanceType,
518+
fmt.Sprintf("instance type %s does not match specified instance family %s", *spec.InstanceType, spec.InstanceFamily)))
519+
}
520+
}
521+
522+
// Validate availability zone format if specified
523+
if spec.AvailabilityZone != nil && *spec.AvailabilityZone != "" {
524+
if !isValidAvailabilityZone(*spec.AvailabilityZone) {
525+
allErrs = append(allErrs, field.Invalid(field.NewPath("spec.dynamicHostAllocation.availabilityZone"), *spec.AvailabilityZone, "invalid availability zone format"))
526+
}
527+
}
528+
468529
return allErrs
469530
}
470531

471532
func (r *AWSMachine) validateSSHKeyName() field.ErrorList {
472533
return validateSSHKeyName(r.Spec.SSHKeyName)
473534
}
535+
536+
// isValidInstanceFamily validates the format of an EC2 instance family.
537+
func isValidInstanceFamily(family string) bool {
538+
// Instance families typically follow patterns like: m5, c5, r5, t3, etc.
539+
// Allow alphanumeric characters, must start with a letter
540+
if len(family) < 2 || len(family) > 10 {
541+
return false
542+
}
543+
544+
for i, char := range family {
545+
if i == 0 {
546+
// First character must be a letter
547+
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z')) {
548+
return false
549+
}
550+
} else {
551+
// Subsequent characters can be letters or numbers
552+
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
553+
return false
554+
}
555+
}
556+
}
557+
return true
558+
}
559+
560+
// isValidInstanceType validates the format of an EC2 instance type.
561+
func isValidInstanceType(instanceType string) bool {
562+
// Instance types follow the pattern: family.size (e.g., m5.large, c5.xlarge)
563+
parts := strings.Split(instanceType, ".")
564+
if len(parts) != 2 {
565+
return false
566+
}
567+
568+
family, size := parts[0], parts[1]
569+
570+
// Validate family part
571+
if !isValidInstanceFamily(family) {
572+
return false
573+
}
574+
575+
// Validate size part - common sizes include: nano, micro, small, medium, large, xlarge, 2xlarge, etc.
576+
validSizes := map[string]bool{
577+
"nano": true, "micro": true, "small": true, "medium": true, "large": true,
578+
"xlarge": true, "2xlarge": true, "3xlarge": true, "4xlarge": true, "6xlarge": true,
579+
"8xlarge": true, "9xlarge": true, "10xlarge": true, "12xlarge": true, "16xlarge": true,
580+
"18xlarge": true, "24xlarge": true, "32xlarge": true, "48xlarge": true, "56xlarge": true,
581+
"112xlarge": true, "224xlarge": true, "metal": true,
582+
}
583+
584+
return validSizes[size]
585+
}
586+
587+
// isValidAvailabilityZone validates the format of an AWS availability zone.
588+
func isValidAvailabilityZone(az string) bool {
589+
// AZ format: region + zone letter (e.g., us-west-2a, eu-central-1b)
590+
if len(az) < 4 {
591+
return false
592+
}
593+
594+
// Should end with a single letter
595+
lastChar := az[len(az)-1]
596+
if !((lastChar >= 'a' && lastChar <= 'z') || (lastChar >= 'A' && lastChar <= 'Z')) {
597+
return false
598+
}
599+
600+
// The rest should be a valid region format (contains dashes and alphanumeric)
601+
region := az[:len(az)-1]
602+
if len(region) < 3 {
603+
return false
604+
}
605+
606+
// Basic validation for region format
607+
for _, char := range region {
608+
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-') {
609+
return false
610+
}
611+
}
612+
613+
return true
614+
}
615+
616+
// extractInstanceFamily extracts the instance family from an instance type.
617+
func extractInstanceFamily(instanceType string) string {
618+
parts := strings.Split(instanceType, ".")
619+
if len(parts) < 2 {
620+
return instanceType
621+
}
622+
return parts[0]
623+
}

api/v1beta2/types.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,38 @@ type Instance struct {
285285
// HostID specifies the dedicated host on which the instance should be started.
286286
// +optional
287287
HostID *string `json:"hostID,omitempty"`
288+
289+
// DynamicHostAllocation enables automatic allocation of dedicated hosts.
290+
// This field is mutually exclusive with HostID.
291+
// +optional
292+
DynamicHostAllocation *DynamicHostAllocationSpec `json:"dynamicHostAllocation,omitempty"`
293+
}
294+
295+
// DedicatedHostInfo contains information about a dedicated host.
296+
type DedicatedHostInfo struct {
297+
// HostID is the ID of the dedicated host.
298+
HostID string `json:"hostID"`
299+
300+
// InstanceFamily is the instance family supported by the host.
301+
InstanceFamily string `json:"instanceFamily"`
302+
303+
// InstanceType is the instance type supported by the host.
304+
InstanceType string `json:"instanceType"`
305+
306+
// AvailabilityZone is the AZ where the host is located.
307+
AvailabilityZone string `json:"availabilityZone"`
308+
309+
// State is the current state of the dedicated host.
310+
State string `json:"state"`
311+
312+
// TotalCapacity is the total number of instances that can be launched on the host.
313+
TotalCapacity int32 `json:"totalCapacity"`
314+
315+
// AvailableCapacity is the number of instances that can still be launched on the host.
316+
AvailableCapacity int32 `json:"availableCapacity"`
317+
318+
// Tags associated with the dedicated host.
319+
Tags map[string]string `json:"tags,omitempty"`
288320
}
289321

290322
// MarketType describes the market type of an Instance

0 commit comments

Comments
 (0)