diff --git a/api/v1beta1/awscluster_conversion.go b/api/v1beta1/awscluster_conversion.go index e6d9982cbd..94f8078f96 100644 --- a/api/v1beta1/awscluster_conversion.go +++ b/api/v1beta1/awscluster_conversion.go @@ -60,6 +60,7 @@ func (src *AWSCluster) ConvertTo(dstRaw conversion.Hub) error { dst.Status.Bastion.PrivateDNSName = restored.Status.Bastion.PrivateDNSName dst.Status.Bastion.PublicIPOnLaunch = restored.Status.Bastion.PublicIPOnLaunch dst.Status.Bastion.CapacityReservationID = restored.Status.Bastion.CapacityReservationID + dst.Status.Bastion.MarketType = restored.Status.Bastion.MarketType } dst.Spec.Partition = restored.Spec.Partition diff --git a/api/v1beta1/awsmachine_conversion.go b/api/v1beta1/awsmachine_conversion.go index 6044416cdf..829bdc912b 100644 --- a/api/v1beta1/awsmachine_conversion.go +++ b/api/v1beta1/awsmachine_conversion.go @@ -42,6 +42,7 @@ func (src *AWSMachine) ConvertTo(dstRaw conversion.Hub) error { dst.Spec.PrivateDNSName = restored.Spec.PrivateDNSName dst.Spec.SecurityGroupOverrides = restored.Spec.SecurityGroupOverrides dst.Spec.CapacityReservationID = restored.Spec.CapacityReservationID + dst.Spec.MarketType = restored.Spec.MarketType if restored.Spec.ElasticIPPool != nil { if dst.Spec.ElasticIPPool == nil { dst.Spec.ElasticIPPool = &infrav1.ElasticIPPool{} @@ -104,6 +105,7 @@ func (r *AWSMachineTemplate) ConvertTo(dstRaw conversion.Hub) error { dst.Spec.Template.Spec.PrivateDNSName = restored.Spec.Template.Spec.PrivateDNSName dst.Spec.Template.Spec.SecurityGroupOverrides = restored.Spec.Template.Spec.SecurityGroupOverrides dst.Spec.Template.Spec.CapacityReservationID = restored.Spec.Template.Spec.CapacityReservationID + dst.Spec.Template.Spec.MarketType = restored.Spec.Template.Spec.MarketType if restored.Spec.Template.Spec.ElasticIPPool != nil { if dst.Spec.Template.Spec.ElasticIPPool == nil { dst.Spec.Template.Spec.ElasticIPPool = &infrav1.ElasticIPPool{} diff --git a/api/v1beta1/zz_generated.conversion.go b/api/v1beta1/zz_generated.conversion.go index 2d1641f424..286b6e7530 100644 --- a/api/v1beta1/zz_generated.conversion.go +++ b/api/v1beta1/zz_generated.conversion.go @@ -1434,6 +1434,7 @@ func autoConvert_v1beta2_AWSMachineSpec_To_v1beta1_AWSMachineSpec(in *v1beta2.AW out.Tenancy = in.Tenancy // WARNING: in.PrivateDNSName requires manual conversion: does not exist in peer-type // WARNING: in.CapacityReservationID requires manual conversion: does not exist in peer-type + // WARNING: in.MarketType requires manual conversion: does not exist in peer-type return nil } @@ -2036,6 +2037,7 @@ func autoConvert_v1beta2_Instance_To_v1beta1_Instance(in *v1beta2.Instance, out // WARNING: in.PrivateDNSName requires manual conversion: does not exist in peer-type // WARNING: in.PublicIPOnLaunch requires manual conversion: does not exist in peer-type // WARNING: in.CapacityReservationID requires manual conversion: does not exist in peer-type + // WARNING: in.MarketType requires manual conversion: does not exist in peer-type return nil } diff --git a/api/v1beta2/awsmachine_types.go b/api/v1beta2/awsmachine_types.go index 39a649a0e5..77df6496e4 100644 --- a/api/v1beta2/awsmachine_types.go +++ b/api/v1beta2/awsmachine_types.go @@ -197,6 +197,15 @@ type AWSMachineSpec struct { // CapacityReservationID specifies the target Capacity Reservation into which the instance should be launched. // +optional CapacityReservationID *string `json:"capacityReservationId,omitempty"` + + // MarketType specifies the type of market for the EC2 instance. Valid values include: + // "OnDemand" (default): The instance runs as a standard OnDemand instance. + // "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + // "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + // If this value is selected, CapacityReservationID must be specified to identify the target reservation. + // If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + // +optional + MarketType MarketType `json:"marketType,omitempty"` } // CloudInit defines options related to the bootstrapping systems where diff --git a/api/v1beta2/awsmachine_webhook.go b/api/v1beta2/awsmachine_webhook.go index 50af4f2211..1937c6ff21 100644 --- a/api/v1beta2/awsmachine_webhook.go +++ b/api/v1beta2/awsmachine_webhook.go @@ -66,6 +66,7 @@ func (r *AWSMachine) ValidateCreate() (admission.Warnings, error) { allErrs = append(allErrs, r.validateAdditionalSecurityGroups()...) allErrs = append(allErrs, r.Spec.AdditionalTags.Validate()...) allErrs = append(allErrs, r.validateNetworkElasticIPPool()...) + allErrs = append(allErrs, r.validateInstanceMarketType()...) return nil, aggregateObjErrors(r.GroupVersionKind().GroupKind(), r.Name, allErrs) } @@ -361,6 +362,20 @@ func (r *AWSMachine) validateNetworkElasticIPPool() field.ErrorList { return allErrs } +func (r *AWSMachine) validateInstanceMarketType() field.ErrorList { + var allErrs field.ErrorList + if r.Spec.MarketType == MarketTypeCapacityBlock && r.Spec.SpotMarketOptions != nil { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "marketType"), "marketType set to CapacityBlock and spotMarketOptions cannot be used together")) + } + if r.Spec.MarketType == MarketTypeOnDemand && r.Spec.SpotMarketOptions != nil { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "marketType"), "setting marketType to OnDemand and spotMarketOptions cannot be used together")) + } + if r.Spec.MarketType == MarketTypeCapacityBlock && r.Spec.CapacityReservationID == nil { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "capacityReservationID"), "is required when CapacityBlock is provided")) + } + return allErrs +} + func (r *AWSMachine) validateNonRootVolumes() field.ErrorList { var allErrs field.ErrorList diff --git a/api/v1beta2/awsmachine_webhook_test.go b/api/v1beta2/awsmachine_webhook_test.go index 80e7abc45a..1944103c99 100644 --- a/api/v1beta2/awsmachine_webhook_test.go +++ b/api/v1beta2/awsmachine_webhook_test.go @@ -217,6 +217,49 @@ func TestAWSMachineCreate(t *testing.T) { }, wantErr: false, }, + { + name: "invalid case, MarketType set to MarketTypeCapacityBlock and spotMarketOptions are specified", + machine: &AWSMachine{ + Spec: AWSMachineSpec{ + MarketType: MarketTypeCapacityBlock, + SpotMarketOptions: &SpotMarketOptions{}, + InstanceType: "test", + }, + }, + wantErr: true, + }, + { + name: "invalid case, MarketType set to MarketTypeOnDemand and spotMarketOptions are specified", + machine: &AWSMachine{ + Spec: AWSMachineSpec{ + MarketType: MarketTypeOnDemand, + SpotMarketOptions: &SpotMarketOptions{}, + InstanceType: "test", + }, + }, + wantErr: true, + }, + { + name: "valid MarketType set to MarketTypeCapacityBlock is specified and CapacityReservationId is not provided", + machine: &AWSMachine{ + Spec: AWSMachineSpec{ + MarketType: MarketTypeCapacityBlock, + InstanceType: "test", + }, + }, + wantErr: true, + }, + { + name: "valid MarketType set to MarketTypeCapacityBlock and CapacityReservationId are specified", + machine: &AWSMachine{ + Spec: AWSMachineSpec{ + MarketType: MarketTypeCapacityBlock, + CapacityReservationID: aws.String("cr-12345678901234567"), + InstanceType: "test", + }, + }, + wantErr: false, + }, { name: "empty instance type not allowed", machine: &AWSMachine{ diff --git a/api/v1beta2/types.go b/api/v1beta2/types.go index 978d5310f2..5c1bcd41c2 100644 --- a/api/v1beta2/types.go +++ b/api/v1beta2/types.go @@ -261,8 +261,32 @@ type Instance struct { // CapacityReservationID specifies the target Capacity Reservation into which the instance should be launched. // +optional CapacityReservationID *string `json:"capacityReservationId,omitempty"` + + // MarketType specifies the type of market for the EC2 instance. Valid values include: + // "OnDemand" (default): The instance runs as a standard OnDemand instance. + // "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + // "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + // If this value is selected, CapacityReservationID must be specified to identify the target reservation. + // If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + // +optional + MarketType MarketType `json:"marketType,omitempty"` } +// MarketType describes the market type of an Instance +// +kubebuilder:validation:Enum:=OnDemand;Spot;CapacityBlock +type MarketType string + +const ( + // MarketTypeOnDemand is a MarketType enum value + MarketTypeOnDemand MarketType = "OnDemand" + + // MarketTypeSpot is a MarketType enum value + MarketTypeSpot MarketType = "Spot" + + // MarketTypeCapacityBlock is a MarketType enum value + MarketTypeCapacityBlock MarketType = "CapacityBlock" +) + // InstanceMetadataState describes the state of InstanceMetadataOptions.HttpEndpoint and InstanceMetadataOptions.InstanceMetadataTags type InstanceMetadataState string diff --git a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml index 21be0fc920..980e4bcd94 100644 --- a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml +++ b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml @@ -1210,6 +1210,19 @@ spec: instanceState: description: The current state of the instance. type: string + marketType: + description: |- + MarketType specifies the type of market for the EC2 instance. Valid values include: + "OnDemand" (default): The instance runs as a standard OnDemand instance. + "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + If this value is selected, CapacityReservationID must be specified to identify the target reservation. + If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + enum: + - OnDemand + - Spot + - CapacityBlock + type: string networkInterfaces: description: Specifies ENIs attached to instance items: @@ -3250,6 +3263,19 @@ spec: instanceState: description: The current state of the instance. type: string + marketType: + description: |- + MarketType specifies the type of market for the EC2 instance. Valid values include: + "OnDemand" (default): The instance runs as a standard OnDemand instance. + "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + If this value is selected, CapacityReservationID must be specified to identify the target reservation. + If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + enum: + - OnDemand + - Spot + - CapacityBlock + type: string networkInterfaces: description: Specifies ENIs attached to instance items: diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml index 3ccb164ec1..21b596abb8 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml @@ -2177,6 +2177,19 @@ spec: instanceState: description: The current state of the instance. type: string + marketType: + description: |- + MarketType specifies the type of market for the EC2 instance. Valid values include: + "OnDemand" (default): The instance runs as a standard OnDemand instance. + "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + If this value is selected, CapacityReservationID must be specified to identify the target reservation. + If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + enum: + - OnDemand + - Spot + - CapacityBlock + type: string networkInterfaces: description: Specifies ENIs attached to instance items: diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml index 6314fe7c62..6fa530d0a8 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml @@ -629,6 +629,10 @@ spec: description: ID of resource type: string type: object + capacityReservationId: + description: CapacityReservationID specifies the target Capacity + Reservation into which the instance should be launched. + type: string iamInstanceProfile: description: |- The name or the Amazon Resource Name (ARN) of the instance profile associated @@ -724,6 +728,19 @@ spec: description: 'InstanceType is the type of instance to create. Example: m4.xlarge' type: string + marketType: + description: |- + MarketType specifies the type of market for the EC2 instance. Valid values include: + "OnDemand" (default): The instance runs as a standard OnDemand instance. + "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + If this value is selected, CapacityReservationID must be specified to identify the target reservation. + If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + enum: + - OnDemand + - Spot + - CapacityBlock + type: string name: description: The name of the launch template. type: string diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml index 70e7728369..eb72154a97 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml @@ -878,6 +878,19 @@ spec: m4.xlarge' minLength: 2 type: string + marketType: + description: |- + MarketType specifies the type of market for the EC2 instance. Valid values include: + "OnDemand" (default): The instance runs as a standard OnDemand instance. + "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + If this value is selected, CapacityReservationID must be specified to identify the target reservation. + If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + enum: + - OnDemand + - Spot + - CapacityBlock + type: string networkInterfaces: description: |- NetworkInterfaces is a list of ENIs to associate with the instance. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml index a679a68bc8..228e6eb0cf 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml @@ -812,6 +812,19 @@ spec: Example: m4.xlarge' minLength: 2 type: string + marketType: + description: |- + MarketType specifies the type of market for the EC2 instance. Valid values include: + "OnDemand" (default): The instance runs as a standard OnDemand instance. + "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + If this value is selected, CapacityReservationID must be specified to identify the target reservation. + If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + enum: + - OnDemand + - Spot + - CapacityBlock + type: string networkInterfaces: description: |- NetworkInterfaces is a list of ENIs to associate with the instance. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml index 9ecac67715..c2d9737bf2 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml @@ -625,6 +625,10 @@ spec: description: ID of resource type: string type: object + capacityReservationId: + description: CapacityReservationID specifies the target Capacity + Reservation into which the instance should be launched. + type: string iamInstanceProfile: description: |- The name or the Amazon Resource Name (ARN) of the instance profile associated @@ -720,6 +724,19 @@ spec: description: 'InstanceType is the type of instance to create. Example: m4.xlarge' type: string + marketType: + description: |- + MarketType specifies the type of market for the EC2 instance. Valid values include: + "OnDemand" (default): The instance runs as a standard OnDemand instance. + "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + If this value is selected, CapacityReservationID must be specified to identify the target reservation. + If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + enum: + - OnDemand + - Spot + - CapacityBlock + type: string name: description: The name of the launch template. type: string diff --git a/exp/api/v1beta1/conversion.go b/exp/api/v1beta1/conversion.go index 7c39f1fcbd..16af878660 100644 --- a/exp/api/v1beta1/conversion.go +++ b/exp/api/v1beta1/conversion.go @@ -57,9 +57,16 @@ func (src *AWSMachinePool) ConvertTo(dstRaw conversion.Hub) error { dst.Spec.AWSLaunchTemplate.PrivateDNSName = restored.Spec.AWSLaunchTemplate.PrivateDNSName } + if restored.Spec.AWSLaunchTemplate.CapacityReservationID != nil { + dst.Spec.AWSLaunchTemplate.CapacityReservationID = restored.Spec.AWSLaunchTemplate.CapacityReservationID + } + + if restored.Spec.AWSLaunchTemplate.MarketType != "" { + dst.Spec.AWSLaunchTemplate.MarketType = restored.Spec.AWSLaunchTemplate.MarketType + } + dst.Spec.DefaultInstanceWarmup = restored.Spec.DefaultInstanceWarmup dst.Spec.AWSLaunchTemplate.NonRootVolumes = restored.Spec.AWSLaunchTemplate.NonRootVolumes - return nil } @@ -109,6 +116,15 @@ func (src *AWSManagedMachinePool) ConvertTo(dstRaw conversion.Hub) error { if restored.Spec.AWSLaunchTemplate.PrivateDNSName != nil { dst.Spec.AWSLaunchTemplate.PrivateDNSName = restored.Spec.AWSLaunchTemplate.PrivateDNSName } + + if restored.Spec.AWSLaunchTemplate.CapacityReservationID != nil { + dst.Spec.AWSLaunchTemplate.CapacityReservationID = restored.Spec.AWSLaunchTemplate.CapacityReservationID + } + + if restored.Spec.AWSLaunchTemplate.MarketType != "" { + dst.Spec.AWSLaunchTemplate.MarketType = restored.Spec.AWSLaunchTemplate.MarketType + } + } if restored.Spec.AvailabilityZoneSubnetType != nil { dst.Spec.AvailabilityZoneSubnetType = restored.Spec.AvailabilityZoneSubnetType diff --git a/exp/api/v1beta1/zz_generated.conversion.go b/exp/api/v1beta1/zz_generated.conversion.go index 585cbd1504..3cb29cd7b1 100644 --- a/exp/api/v1beta1/zz_generated.conversion.go +++ b/exp/api/v1beta1/zz_generated.conversion.go @@ -414,6 +414,8 @@ func autoConvert_v1beta2_AWSLaunchTemplate_To_v1beta1_AWSLaunchTemplate(in *v1be out.SpotMarketOptions = (*apiv1beta2.SpotMarketOptions)(unsafe.Pointer(in.SpotMarketOptions)) // WARNING: in.InstanceMetadataOptions requires manual conversion: does not exist in peer-type // WARNING: in.PrivateDNSName requires manual conversion: does not exist in peer-type + // WARNING: in.CapacityReservationID requires manual conversion: does not exist in peer-type + // WARNING: in.MarketType requires manual conversion: does not exist in peer-type return nil } diff --git a/exp/api/v1beta2/awsmachinepool_webhook.go b/exp/api/v1beta2/awsmachinepool_webhook.go index a4f6a44d41..339db50436 100644 --- a/exp/api/v1beta2/awsmachinepool_webhook.go +++ b/exp/api/v1beta2/awsmachinepool_webhook.go @@ -176,6 +176,7 @@ func (r *AWSMachinePool) ValidateCreate() (admission.Warnings, error) { allErrs = append(allErrs, r.validateAdditionalSecurityGroups()...) allErrs = append(allErrs, r.validateSpotInstances()...) allErrs = append(allErrs, r.validateRefreshPreferences()...) + allErrs = append(allErrs, r.validateInstanceMarketType()...) if len(allErrs) == 0 { return nil, nil @@ -188,6 +189,22 @@ func (r *AWSMachinePool) ValidateCreate() (admission.Warnings, error) { ) } +func (r *AWSMachinePool) validateInstanceMarketType() field.ErrorList { + var allErrs field.ErrorList + if r.Spec.AWSLaunchTemplate.MarketType == v1beta2.MarketTypeCapacityBlock && r.Spec.AWSLaunchTemplate.SpotMarketOptions != nil { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.marketType"), "setting marketType to CapacityBlock and spotMarketOptions cannot be used together")) + } + if r.Spec.AWSLaunchTemplate.MarketType == v1beta2.MarketTypeOnDemand && r.Spec.AWSLaunchTemplate.SpotMarketOptions != nil { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.marketType"), "setting marketType to OnDemand and spotMarketOptions cannot be used together")) + } + + if r.Spec.AWSLaunchTemplate.MarketType == v1beta2.MarketTypeCapacityBlock && r.Spec.AWSLaunchTemplate.CapacityReservationID == nil { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.capacityReservationID"), "is required when CapacityBlock is provided")) + } + + return allErrs +} + // ValidateUpdate will do any extra validation when updating a AWSMachinePool. func (r *AWSMachinePool) ValidateUpdate(_ runtime.Object) (admission.Warnings, error) { var allErrs field.ErrorList diff --git a/exp/api/v1beta2/awsmachinepool_webhook_test.go b/exp/api/v1beta2/awsmachinepool_webhook_test.go index 0f14ad1c0a..d41e86630f 100644 --- a/exp/api/v1beta2/awsmachinepool_webhook_test.go +++ b/exp/api/v1beta2/awsmachinepool_webhook_test.go @@ -174,6 +174,53 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, wantErr: true, }, + { + name: "invalid, MarketType set to MarketTypeCapacityBlock and spotMarketOptions are specified", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLaunchTemplate: AWSLaunchTemplate{ + MarketType: infrav1.MarketTypeCapacityBlock, + SpotMarketOptions: &infrav1.SpotMarketOptions{}, + }, + }, + }, + wantErr: true, + }, + { + name: "invalid, MarketType set to MarketTypeOnDemand and spotMarketOptions are specified", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLaunchTemplate: AWSLaunchTemplate{ + MarketType: infrav1.MarketTypeOnDemand, + SpotMarketOptions: &infrav1.SpotMarketOptions{}, + }, + }, + }, + wantErr: true, + }, + { + name: "valid MarketType set to MarketTypeCapacityBlock is specified and CapacityReservationId is not provided", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLaunchTemplate: AWSLaunchTemplate{ + MarketType: infrav1.MarketTypeCapacityBlock, + }, + }, + }, + wantErr: true, + }, + { + name: "valid MarketType set to MarketTypeCapacityBlock and CapacityReservationId are specified", + pool: &AWSMachinePool{ + Spec: AWSMachinePoolSpec{ + AWSLaunchTemplate: AWSLaunchTemplate{ + MarketType: infrav1.MarketTypeCapacityBlock, + CapacityReservationID: aws.String("cr-12345678901234567"), + }, + }, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/exp/api/v1beta2/types.go b/exp/api/v1beta2/types.go index 0bc4009a2e..c4455d4f83 100644 --- a/exp/api/v1beta2/types.go +++ b/exp/api/v1beta2/types.go @@ -128,6 +128,19 @@ type AWSLaunchTemplate struct { // PrivateDNSName is the options for the instance hostname. // +optional PrivateDNSName *infrav1.PrivateDNSName `json:"privateDnsName,omitempty"` + + // CapacityReservationID specifies the target Capacity Reservation into which the instance should be launched. + // +optional + CapacityReservationID *string `json:"capacityReservationId,omitempty"` + + // MarketType specifies the type of market for the EC2 instance. Valid values include: + // "OnDemand" (default): The instance runs as a standard OnDemand instance. + // "Spot": The instance runs as a Spot instance. When SpotMarketOptions is provided, the marketType defaults to "Spot". + // "CapacityBlock": The instance utilizes pre-purchased compute capacity (capacity blocks) with AWS Capacity Reservations. + // If this value is selected, CapacityReservationID must be specified to identify the target reservation. + // If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". + // +optional + MarketType infrav1.MarketType `json:"marketType,omitempty"` } // Overrides are used to override the instance type specified by the launch template with multiple diff --git a/exp/api/v1beta2/zz_generated.deepcopy.go b/exp/api/v1beta2/zz_generated.deepcopy.go index f34e8f9d9b..8c016a39ea 100644 --- a/exp/api/v1beta2/zz_generated.deepcopy.go +++ b/exp/api/v1beta2/zz_generated.deepcopy.go @@ -136,6 +136,11 @@ func (in *AWSLaunchTemplate) DeepCopyInto(out *AWSLaunchTemplate) { *out = new(apiv1beta2.PrivateDNSName) (*in).DeepCopyInto(*out) } + if in.CapacityReservationID != nil { + in, out := &in.CapacityReservationID, &out.CapacityReservationID + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSLaunchTemplate. diff --git a/pkg/cloud/services/ec2/helper_test.go b/pkg/cloud/services/ec2/helper_test.go index 0a0b67ab0a..eb296f85b7 100644 --- a/pkg/cloud/services/ec2/helper_test.go +++ b/pkg/cloud/services/ec2/helper_test.go @@ -61,6 +61,45 @@ func setupMachinePoolScope(cl client.Client, ec2Scope scope.EC2Scope) (*scope.Ma }) } +func setupCapacityBlocksMachinePoolScope(cl client.Client, ec2Scope scope.EC2Scope) (*scope.MachinePoolScope, error) { + return scope.NewMachinePoolScope(scope.MachinePoolScopeParams{ + Client: cl, + InfraCluster: ec2Scope, + Cluster: newCluster(), + MachinePool: newMachinePool(), + AWSMachinePool: newAWSCapacityBlockMachinePool(), + }) +} + +func newAWSCapacityBlockMachinePool() *expinfrav1.AWSMachinePool { + return &expinfrav1.AWSMachinePool{ + TypeMeta: metav1.TypeMeta{ + Kind: "AWSMachinePool", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "aws-mp-name", + Namespace: "aws-mp-ns", + }, + Spec: expinfrav1.AWSMachinePoolSpec{ + AvailabilityZones: []string{"us-east-1"}, + AdditionalTags: infrav1.Tags{}, + AWSLaunchTemplate: expinfrav1.AWSLaunchTemplate{ + Name: "aws-launch-template", + IamInstanceProfile: "instance-profile", + AMI: infrav1.AMIReference{}, + InstanceType: "t3.large", + SSHKeyName: aws.String("default"), + MarketType: infrav1.MarketTypeCapacityBlock, + CapacityReservationID: aws.String("cr-12345678901234567"), + }, + }, + Status: expinfrav1.AWSMachinePoolStatus{ + LaunchTemplateID: "launch-template-id", + }, + } +} + func defaultEC2Tags(name, clusterName string) []*ec2.Tag { return []*ec2.Tag{ { diff --git a/pkg/cloud/services/ec2/instances.go b/pkg/cloud/services/ec2/instances.go index 1da1893ff7..820ddda711 100644 --- a/pkg/cloud/services/ec2/instances.go +++ b/pkg/cloud/services/ec2/instances.go @@ -253,6 +253,8 @@ func (s *Service) CreateInstance(scope *scope.MachineScope, userData []byte, use input.CapacityReservationID = scope.AWSMachine.Spec.CapacityReservationID + input.MarketType = scope.AWSMachine.Spec.MarketType + s.scope.Debug("Running instance", "machine-role", scope.Role()) s.scope.Debug("Running instance with instance metadata options", "metadata options", input.InstanceMetadataOptions) out, err := s.runInstance(scope.Role(), input) @@ -637,8 +639,13 @@ func (s *Service) runInstance(role string, i *infrav1.Instance) (*infrav1.Instan input.TagSpecifications = append(input.TagSpecifications, spec) } } - - input.InstanceMarketOptions = getInstanceMarketOptionsRequest(i.SpotMarketOptions) + marketOptions, err := getInstanceMarketOptionsRequest(i) + if err != nil { + return nil, err + } + if marketOptions != nil { + input.InstanceMarketOptions = marketOptions + } input.MetadataOptions = getInstanceMetadataOptionsRequest(i.InstanceMetadataOptions) input.PrivateDnsNameOptions = getPrivateDNSNameOptionsRequest(i.PrivateDNSName) input.CapacityReservationSpecification = getCapacityReservationSpecification(i.CapacityReservationID) @@ -1138,34 +1145,57 @@ func getCapacityReservationSpecification(capacityReservationID *string) *ec2.Cap } } -func getInstanceMarketOptionsRequest(spotMarketOptions *infrav1.SpotMarketOptions) *ec2.InstanceMarketOptionsRequest { - if spotMarketOptions == nil { - // Instance is not a Spot instance - return nil +func getInstanceMarketOptionsRequest(i *infrav1.Instance) (*ec2.InstanceMarketOptionsRequest, error) { + if i.MarketType != "" && i.MarketType == infrav1.MarketTypeCapacityBlock && i.SpotMarketOptions != nil { + return nil, errors.New("can't create spot capacity-blocks, remove spot market request") } - // Set required values for Spot instances - spotOptions := &ec2.SpotMarketOptions{} - - // The following two options ensure that: - // - If an instance is interrupted, it is terminated rather than hibernating or stopping - // - No replacement instance will be created if the instance is interrupted - // - If the spot request cannot immediately be fulfilled, it will not be created - // This behaviour should satisfy the 1:1 mapping of Machines to Instances as - // assumed by the Cluster API. - spotOptions.SetInstanceInterruptionBehavior(ec2.InstanceInterruptionBehaviorTerminate) - spotOptions.SetSpotInstanceType(ec2.SpotInstanceTypeOneTime) + // Infer MarketType if not explicitly set + if i.SpotMarketOptions != nil && i.MarketType == "" { + i.MarketType = infrav1.MarketTypeSpot + } - maxPrice := spotMarketOptions.MaxPrice - if maxPrice != nil && *maxPrice != "" { - spotOptions.SetMaxPrice(*maxPrice) + if i.MarketType == "" { + i.MarketType = infrav1.MarketTypeOnDemand } - instanceMarketOptionsRequest := &ec2.InstanceMarketOptionsRequest{} - instanceMarketOptionsRequest.SetMarketType(ec2.MarketTypeSpot) - instanceMarketOptionsRequest.SetSpotOptions(spotOptions) + switch i.MarketType { + case infrav1.MarketTypeCapacityBlock: + if i.CapacityReservationID == nil { + return nil, errors.Errorf("capacityReservationID is required when CapacityBlock is enabled") + } + return &ec2.InstanceMarketOptionsRequest{ + MarketType: aws.String(ec2.MarketTypeCapacityBlock), + }, nil + + case infrav1.MarketTypeSpot: + // Set required values for Spot instances + spotOpts := &ec2.SpotMarketOptions{ + // The following two options ensure that: + // - If an instance is interrupted, it is terminated rather than hibernating or stopping + // - No replacement instance will be created if the instance is interrupted + // - If the spot request cannot immediately be fulfilled, it will not be created + // This behaviour should satisfy the 1:1 mapping of Machines to Instances as + // assumed by the Cluster API. + InstanceInterruptionBehavior: aws.String(ec2.InstanceInterruptionBehaviorTerminate), + SpotInstanceType: aws.String(ec2.SpotInstanceTypeOneTime), + } + + if maxPrice := aws.StringValue(i.SpotMarketOptions.MaxPrice); maxPrice != "" { + spotOpts.MaxPrice = aws.String(maxPrice) + } - return instanceMarketOptionsRequest + return &ec2.InstanceMarketOptionsRequest{ + MarketType: aws.String(ec2.MarketTypeSpot), + SpotOptions: spotOpts, + }, nil + case infrav1.MarketTypeOnDemand: + // Instance is on-demand or empty + return nil, nil + default: + // Invalid MarketType provided + return nil, errors.Errorf("invalid MarketType %q", i.MarketType) + } } func getInstanceMetadataOptionsRequest(metadataOptions *infrav1.InstanceMetadataOptions) *ec2.InstanceMetadataOptionsRequest { diff --git a/pkg/cloud/services/ec2/instances_test.go b/pkg/cloud/services/ec2/instances_test.go index d8e48602ee..a94828dd77 100644 --- a/pkg/cloud/services/ec2/instances_test.go +++ b/pkg/cloud/services/ec2/instances_test.go @@ -5197,6 +5197,332 @@ func TestCreateInstance(t *testing.T) { } }, }, + { + name: "Simple, setting MarketType to MarketTypeCapacityBlock and providing CapacityReservationID", + machine: &clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"set": "node"}, + }, + Spec: clusterv1.MachineSpec{ + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: ptr.To[string]("bootstrap-data"), + }, + }, + }, + machineConfig: &infrav1.AWSMachineSpec{ + AMI: infrav1.AMIReference{ + ID: aws.String("abc"), + }, + InstanceType: "m5.large", + MarketType: infrav1.MarketTypeCapacityBlock, + CapacityReservationID: aws.String("cr-12345678901234567"), + }, + awsCluster: &infrav1.AWSCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: infrav1.AWSClusterSpec{ + + NetworkSpec: infrav1.NetworkSpec{ + Subnets: infrav1.Subnets{ + infrav1.SubnetSpec{ + ID: "subnet-1", + IsPublic: false, + }, + infrav1.SubnetSpec{ + IsPublic: false, + }, + }, + VPC: infrav1.VPCSpec{ + ID: "vpc-test", + }, + }, + }, + Status: infrav1.AWSClusterStatus{ + Network: infrav1.NetworkStatus{ + SecurityGroups: map[infrav1.SecurityGroupRole]infrav1.SecurityGroup{ + infrav1.SecurityGroupControlPlane: { + ID: "1", + }, + infrav1.SecurityGroupNode: { + ID: "2", + }, + infrav1.SecurityGroupLB: { + ID: "3", + }, + }, + APIServerELB: infrav1.LoadBalancer{ + DNSName: "test-apiserver.us-east-1.aws", + }, + }, + }, + }, + expect: func(m *mocks.MockEC2APIMockRecorder) { + m. + DescribeInstanceTypesWithContext(context.TODO(), gomock.Eq(&ec2.DescribeInstanceTypesInput{ + InstanceTypes: []*string{ + aws.String("m5.large"), + }, + })). + Return(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + { + ProcessorInfo: &ec2.ProcessorInfo{ + SupportedArchitectures: []*string{ + aws.String("x86_64"), + }, + }, + }, + }, + }, nil) + m. // TODO: Restore these parameters, but with the tags as well + RunInstancesWithContext(context.TODO(), gomock.Any()). + Return(&ec2.Reservation{ + Instances: []*ec2.Instance{ + { + State: &ec2.InstanceState{ + Name: aws.String(ec2.InstanceStateNamePending), + }, + IamInstanceProfile: &ec2.IamInstanceProfile{ + Arn: aws.String("arn:aws:iam::123456789012:instance-profile/foo"), + }, + InstanceId: aws.String("two"), + InstanceType: aws.String("m5.large"), + SubnetId: aws.String("subnet-1"), + ImageId: aws.String("ami-1"), + RootDeviceName: aws.String("device-1"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + { + DeviceName: aws.String("device-1"), + Ebs: &ec2.EbsInstanceBlockDevice{ + VolumeId: aws.String("volume-1"), + }, + }, + }, + Placement: &ec2.Placement{ + AvailabilityZone: &az, + }, + InstanceLifecycle: aws.String(ec2.MarketTypeCapacityBlock), + CapacityReservationId: aws.String("cr-12345678901234567"), + }, + }, + }, nil) + m. + DescribeNetworkInterfacesWithContext(context.TODO(), gomock.Any()). + Return(&ec2.DescribeNetworkInterfacesOutput{ + NetworkInterfaces: []*ec2.NetworkInterface{}, + NextToken: nil, + }, nil) + }, + check: func(instance *infrav1.Instance, err error) { + if err != nil { + t.Fatalf("did not expect error: %v", err) + } + }, + }, + { + name: "expect error when MarketType to MarketTypeCapacityBlock set but not providing CapacityReservationID", + machine: &clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"set": "node"}, + Namespace: "default", + Name: "machine-aws-test1", + }, + Spec: clusterv1.MachineSpec{ + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: ptr.To[string]("bootstrap-data"), + }, + }, + }, + machineConfig: &infrav1.AWSMachineSpec{ + AMI: infrav1.AMIReference{ + ID: aws.String("abc"), + }, + MarketType: infrav1.MarketTypeCapacityBlock, + InstanceType: "m5.large", + PlacementGroupPartition: 2, + UncompressedUserData: &isUncompressedFalse, + }, + awsCluster: &infrav1.AWSCluster{ + Spec: infrav1.AWSClusterSpec{ + NetworkSpec: infrav1.NetworkSpec{ + Subnets: infrav1.Subnets{ + infrav1.SubnetSpec{ + ID: "subnet-1", + IsPublic: false, + }, + infrav1.SubnetSpec{ + IsPublic: false, + }, + }, + }, + }, + Status: infrav1.AWSClusterStatus{ + Network: infrav1.NetworkStatus{ + SecurityGroups: map[infrav1.SecurityGroupRole]infrav1.SecurityGroup{ + infrav1.SecurityGroupControlPlane: { + ID: "1", + }, + infrav1.SecurityGroupNode: { + ID: "2", + }, + infrav1.SecurityGroupLB: { + ID: "3", + }, + }, + APIServerELB: infrav1.LoadBalancer{ + DNSName: "test-apiserver.us-east-1.aws", + }, + }, + }, + }, + expect: func(m *mocks.MockEC2APIMockRecorder) { + m. + DescribeInstanceTypesWithContext(context.TODO(), gomock.Eq(&ec2.DescribeInstanceTypesInput{ + InstanceTypes: []*string{ + aws.String("m5.large"), + }, + })). + Return(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + { + ProcessorInfo: &ec2.ProcessorInfo{ + SupportedArchitectures: []*string{ + aws.String("x86_64"), + }, + }, + }, + }, + }, nil) + }, + check: func(instance *infrav1.Instance, err error) { + expectedErrMsg := "capacityReservationID is required when CapacityBlock is enabled" + if err == nil { + t.Fatalf("Expected error, but got nil") + } + if !strings.Contains(err.Error(), expectedErrMsg) { + t.Fatalf("Expected error: %s\nInstead got: `%s", expectedErrMsg, err.Error()) + } + }, + }, + { + name: "Simple, setting not setting MarketType and proving CapacityReservationID", + machine: &clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"set": "node"}, + }, + Spec: clusterv1.MachineSpec{ + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: ptr.To[string]("bootstrap-data"), + }, + }, + }, + machineConfig: &infrav1.AWSMachineSpec{ + AMI: infrav1.AMIReference{ + ID: aws.String("abc"), + }, + InstanceType: "m5.large", + CapacityReservationID: aws.String("cr-12345678901234567"), + }, + awsCluster: &infrav1.AWSCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: infrav1.AWSClusterSpec{ + + NetworkSpec: infrav1.NetworkSpec{ + Subnets: infrav1.Subnets{ + infrav1.SubnetSpec{ + ID: "subnet-1", + IsPublic: false, + }, + infrav1.SubnetSpec{ + IsPublic: false, + }, + }, + VPC: infrav1.VPCSpec{ + ID: "vpc-test", + }, + }, + }, + Status: infrav1.AWSClusterStatus{ + Network: infrav1.NetworkStatus{ + SecurityGroups: map[infrav1.SecurityGroupRole]infrav1.SecurityGroup{ + infrav1.SecurityGroupControlPlane: { + ID: "1", + }, + infrav1.SecurityGroupNode: { + ID: "2", + }, + infrav1.SecurityGroupLB: { + ID: "3", + }, + }, + APIServerELB: infrav1.LoadBalancer{ + DNSName: "test-apiserver.us-east-1.aws", + }, + }, + }, + }, + expect: func(m *mocks.MockEC2APIMockRecorder) { + m. + DescribeInstanceTypesWithContext(context.TODO(), gomock.Eq(&ec2.DescribeInstanceTypesInput{ + InstanceTypes: []*string{ + aws.String("m5.large"), + }, + })). + Return(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + { + ProcessorInfo: &ec2.ProcessorInfo{ + SupportedArchitectures: []*string{ + aws.String("x86_64"), + }, + }, + }, + }, + }, nil) + m. // TODO: Restore these parameters, but with the tags as well + RunInstancesWithContext(context.TODO(), gomock.Any()). + Return(&ec2.Reservation{ + Instances: []*ec2.Instance{ + { + State: &ec2.InstanceState{ + Name: aws.String(ec2.InstanceStateNamePending), + }, + IamInstanceProfile: &ec2.IamInstanceProfile{ + Arn: aws.String("arn:aws:iam::123456789012:instance-profile/foo"), + }, + InstanceId: aws.String("two"), + InstanceType: aws.String("m5.large"), + SubnetId: aws.String("subnet-1"), + ImageId: aws.String("ami-1"), + RootDeviceName: aws.String("device-1"), + BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{ + { + DeviceName: aws.String("device-1"), + Ebs: &ec2.EbsInstanceBlockDevice{ + VolumeId: aws.String("volume-1"), + }, + }, + }, + Placement: &ec2.Placement{ + AvailabilityZone: &az, + }, + CapacityReservationId: aws.String("cr-12345678901234567"), + InstanceLifecycle: aws.String("scheduled"), + }, + }, + }, nil) + m. + DescribeNetworkInterfacesWithContext(context.TODO(), gomock.Any()). + Return(&ec2.DescribeNetworkInterfacesOutput{ + NetworkInterfaces: []*ec2.NetworkInterface{}, + NextToken: nil, + }, nil) + }, + check: func(instance *infrav1.Instance, err error) { + if err != nil { + t.Fatalf("did not expect error: %v", err) + } + }, + }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { @@ -5273,19 +5599,40 @@ func TestCreateInstance(t *testing.T) { } func TestGetInstanceMarketOptionsRequest(t *testing.T) { + mockCapacityReservationID := ptr.To[string]("cr-123") testCases := []struct { - name string - spotMarketOptions *infrav1.SpotMarketOptions - expectedRequest *ec2.InstanceMarketOptionsRequest + name string + instance *infrav1.Instance + expectedRequest *ec2.InstanceMarketOptionsRequest + expectedError error }{ { - name: "with no Spot options specified", - spotMarketOptions: nil, - expectedRequest: nil, + name: "with no Spot options specified", + expectedRequest: nil, + instance: &infrav1.Instance{ + SpotMarketOptions: nil, + }, + expectedError: nil, + }, + { + name: "with no MarketType", + expectedRequest: nil, + instance: &infrav1.Instance{}, + expectedError: nil, }, { - name: "with an empty Spot options specified", - spotMarketOptions: &infrav1.SpotMarketOptions{}, + name: "invalid MarketType specified", + expectedRequest: nil, + instance: &infrav1.Instance{ + MarketType: infrav1.MarketType("inValid"), + }, + expectedError: errors.New("invalid MarketType \"inValid\""), + }, + { + name: "with an empty Spot options specified", + instance: &infrav1.Instance{ + SpotMarketOptions: &infrav1.SpotMarketOptions{}, + }, expectedRequest: &ec2.InstanceMarketOptionsRequest{ MarketType: aws.String(ec2.MarketTypeSpot), SpotOptions: &ec2.SpotMarketOptions{ @@ -5293,11 +5640,14 @@ func TestGetInstanceMarketOptionsRequest(t *testing.T) { SpotInstanceType: aws.String(ec2.SpotInstanceTypeOneTime), }, }, + expectedError: nil, }, { name: "with an empty MaxPrice specified", - spotMarketOptions: &infrav1.SpotMarketOptions{ - MaxPrice: aws.String(""), + instance: &infrav1.Instance{ + SpotMarketOptions: &infrav1.SpotMarketOptions{ + MaxPrice: aws.String(""), + }, }, expectedRequest: &ec2.InstanceMarketOptionsRequest{ MarketType: aws.String(ec2.MarketTypeSpot), @@ -5306,11 +5656,14 @@ func TestGetInstanceMarketOptionsRequest(t *testing.T) { SpotInstanceType: aws.String(ec2.SpotInstanceTypeOneTime), }, }, + expectedError: nil, }, { name: "with a valid MaxPrice specified", - spotMarketOptions: &infrav1.SpotMarketOptions{ - MaxPrice: aws.String("0.01"), + instance: &infrav1.Instance{ + SpotMarketOptions: &infrav1.SpotMarketOptions{ + MaxPrice: aws.String("0.01"), + }, }, expectedRequest: &ec2.InstanceMarketOptionsRequest{ MarketType: aws.String(ec2.MarketTypeSpot), @@ -5320,15 +5673,56 @@ func TestGetInstanceMarketOptionsRequest(t *testing.T) { MaxPrice: aws.String("0.01"), }, }, + expectedError: nil, + }, + { + name: "with no MarketTypeCapacityBlock options specified", + instance: &infrav1.Instance{}, + expectedRequest: nil, + expectedError: nil, + }, + { + name: "with a MarketType to MarketTypeCapacityBlock specified with capacityReservationID set to nil", + instance: &infrav1.Instance{ + MarketType: infrav1.MarketTypeCapacityBlock, + CapacityReservationID: nil, + }, + expectedRequest: nil, + expectedError: errors.Errorf("capacityReservationID is required when CapacityBlock is enabled"), + }, + { + name: "with a MarketType to MarketTypeCapacityBlock with capacityReservationID set to nil", + instance: &infrav1.Instance{ + MarketType: infrav1.MarketTypeCapacityBlock, + CapacityReservationID: mockCapacityReservationID, + }, + expectedRequest: &ec2.InstanceMarketOptionsRequest{ + MarketType: aws.String(ec2.MarketTypeCapacityBlock), + }, + expectedError: nil, + }, + { + name: "with a MarketType to MarketTypeCapacityBlock set with capacityReservationID set and empty Spot options specified", + instance: &infrav1.Instance{ + MarketType: infrav1.MarketTypeCapacityBlock, + SpotMarketOptions: &infrav1.SpotMarketOptions{}, + CapacityReservationID: mockCapacityReservationID, + }, + expectedRequest: nil, + expectedError: errors.New("can't create spot capacity-blocks, remove spot market request"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - request := getInstanceMarketOptionsRequest(tc.spotMarketOptions) - if !cmp.Equal(request, tc.expectedRequest) { - t.Errorf("Case: %s. Got: %v, expected: %v", tc.name, request, tc.expectedRequest) + request, err := getInstanceMarketOptionsRequest(tc.instance) + g := NewWithT(t) + if tc.expectedError != nil { + g.Expect(err.Error()).To(Equal(tc.expectedError.Error())) + } else { + g.Expect(err).To(BeNil()) } + g.Expect(request).To(Equal(tc.expectedRequest)) }) } } diff --git a/pkg/cloud/services/ec2/launchtemplate.go b/pkg/cloud/services/ec2/launchtemplate.go index 5da57f2521..95df33c5a0 100644 --- a/pkg/cloud/services/ec2/launchtemplate.go +++ b/pkg/cloud/services/ec2/launchtemplate.go @@ -519,7 +519,11 @@ func (s *Service) createLaunchTemplateData(scope scope.LaunchTemplateScope, imag // set the AMI ID data.ImageId = imageID - data.InstanceMarketOptions = getLaunchTemplateInstanceMarketOptionsRequest(scope.GetLaunchTemplate().SpotMarketOptions) + instanceMarketOptions, err := getLaunchTemplateInstanceMarketOptionsRequest(scope.GetLaunchTemplate()) + if err != nil { + return nil, err + } + data.InstanceMarketOptions = instanceMarketOptions data.PrivateDnsNameOptions = getLaunchTemplatePrivateDNSNameOptionsRequest(scope.GetLaunchTemplate().PrivateDNSName) blockDeviceMappings := []*ec2.LaunchTemplateBlockDeviceMappingRequest{} @@ -962,27 +966,54 @@ func (s *Service) getFilteredSecurityGroupIDs(securityGroup infrav1.AWSResourceR return ids, nil } -func getLaunchTemplateInstanceMarketOptionsRequest(spotMarketOptions *infrav1.SpotMarketOptions) *ec2.LaunchTemplateInstanceMarketOptionsRequest { - if spotMarketOptions == nil { - // Instance is not a Spot instance - return nil +func getLaunchTemplateInstanceMarketOptionsRequest(i *expinfrav1.AWSLaunchTemplate) (*ec2.LaunchTemplateInstanceMarketOptionsRequest, error) { + if i.MarketType != "" && i.MarketType == infrav1.MarketTypeCapacityBlock && i.SpotMarketOptions != nil { + return nil, errors.New("can't create spot capacity-blocks, remove spot market request") } - // Set required values for Spot instances - spotOptions := &ec2.LaunchTemplateSpotMarketOptionsRequest{} - - // Persistent option is not available for EC2 autoscaling, EC2 makes a one-time request by default and setting request type should not be allowed. - // For one-time requests, only terminate option is available as interruption behavior, and default for spotOptions.SetInstanceInterruptionBehavior() is terminate, so it is not set here explicitly. + // Infer MarketType if not explicitly set and SpotMarketOptions specified + if i.SpotMarketOptions != nil && i.MarketType == "" { + i.MarketType = infrav1.MarketTypeSpot + } - if maxPrice := aws.StringValue(spotMarketOptions.MaxPrice); maxPrice != "" { - spotOptions.SetMaxPrice(maxPrice) + // Infer MarketType if not explicitly set + if i.MarketType == "" { + i.MarketType = infrav1.MarketTypeOnDemand } - launchTemplateInstanceMarketOptionsRequest := &ec2.LaunchTemplateInstanceMarketOptionsRequest{} - launchTemplateInstanceMarketOptionsRequest.SetMarketType(ec2.MarketTypeSpot) - launchTemplateInstanceMarketOptionsRequest.SetSpotOptions(spotOptions) + switch i.MarketType { + case infrav1.MarketTypeCapacityBlock: + // Handle Capacity Block case. + if i.CapacityReservationID == nil { + return nil, errors.Errorf("capacityReservationID is required when CapacityBlock is enabled") + } + return &ec2.LaunchTemplateInstanceMarketOptionsRequest{ + MarketType: aws.String(ec2.MarketTypeCapacityBlock), + }, nil + + case infrav1.MarketTypeSpot: + // Set required values for Spot instances + spotOptions := &ec2.LaunchTemplateSpotMarketOptionsRequest{} + + // Persistent option is not available for EC2 autoscaling, EC2 makes a one-time request by default and setting request type should not be allowed. + // For one-time requests, only terminate option is available as interruption behavior, and default for spotOptions.SetInstanceInterruptionBehavior() is terminate, so it is not set here explicitly. + + if maxPrice := aws.StringValue(i.SpotMarketOptions.MaxPrice); maxPrice != "" { + spotOptions.SetMaxPrice(maxPrice) + } - return launchTemplateInstanceMarketOptionsRequest + launchTemplateInstanceMarketOptionsRequest := &ec2.LaunchTemplateInstanceMarketOptionsRequest{} + launchTemplateInstanceMarketOptionsRequest.SetMarketType(ec2.MarketTypeSpot) + launchTemplateInstanceMarketOptionsRequest.SetSpotOptions(spotOptions) + + return launchTemplateInstanceMarketOptionsRequest, nil + case infrav1.MarketTypeOnDemand: + // Instance is on-demand + return nil, nil + default: + // Invalid MarketType provided + return nil, errors.Errorf("invalid MarketType %s, must be spot/capacity-block or empty", i.MarketType) + } } func getLaunchTemplatePrivateDNSNameOptionsRequest(privateDNSName *infrav1.PrivateDNSName) *ec2.LaunchTemplatePrivateDnsNameOptionsRequest { diff --git a/pkg/cloud/services/ec2/launchtemplate_test.go b/pkg/cloud/services/ec2/launchtemplate_test.go index 4553ad4546..a15243fe11 100644 --- a/pkg/cloud/services/ec2/launchtemplate_test.go +++ b/pkg/cloud/services/ec2/launchtemplate_test.go @@ -1076,6 +1076,7 @@ func TestCreateLaunchTemplateVersion(t *testing.T) { awsResourceReference []infrav1.AWSResourceReference expect func(m *mocks.MockEC2APIMockRecorder) wantErr bool + marketType *string }{ { name: "Should successfully creates launch template version", @@ -1128,6 +1129,55 @@ func TestCreateLaunchTemplateVersion(t *testing.T) { }) }, }, + { + name: "Should successfully create launch template version with capacity-block", + awsResourceReference: []infrav1.AWSResourceReference{{ID: aws.String("1")}}, + marketType: aws.String(ec2.MarketTypeCapacityBlock), + expect: func(m *mocks.MockEC2APIMockRecorder) { + sgMap := make(map[infrav1.SecurityGroupRole]infrav1.SecurityGroup) + sgMap[infrav1.SecurityGroupNode] = infrav1.SecurityGroup{ID: "1"} + sgMap[infrav1.SecurityGroupLB] = infrav1.SecurityGroup{ID: "2"} + + expectedInput := &ec2.CreateLaunchTemplateVersionInput{ + LaunchTemplateData: &ec2.RequestLaunchTemplateData{ + InstanceType: aws.String("t3.large"), + IamInstanceProfile: &ec2.LaunchTemplateIamInstanceProfileSpecificationRequest{ + Name: aws.String("instance-profile"), + }, + KeyName: aws.String("default"), + UserData: ptr.To[string](base64.StdEncoding.EncodeToString(userData)), + SecurityGroupIds: aws.StringSlice([]string{"nodeSG", "lbSG", "1"}), + ImageId: aws.String("imageID"), + InstanceMarketOptions: &ec2.LaunchTemplateInstanceMarketOptionsRequest{ + MarketType: aws.String(ec2.MarketTypeCapacityBlock), + }, + TagSpecifications: []*ec2.LaunchTemplateTagSpecificationRequest{ + { + ResourceType: aws.String(ec2.ResourceTypeInstance), + Tags: defaultEC2AndUserDataSecretKeyTags("aws-mp-name", "cluster-name", userDataSecretKey), + }, + { + ResourceType: aws.String(ec2.ResourceTypeVolume), + Tags: defaultEC2Tags("aws-mp-name", "cluster-name"), + }, + }, + }, + LaunchTemplateId: aws.String("launch-template-id"), + } + m.CreateLaunchTemplateVersionWithContext(context.TODO(), gomock.AssignableToTypeOf(expectedInput)).Return(&ec2.CreateLaunchTemplateVersionOutput{ + LaunchTemplateVersion: &ec2.LaunchTemplateVersion{ + LaunchTemplateId: aws.String("launch-template-id"), + }, + }, nil).Do( + func(ctx context.Context, arg *ec2.CreateLaunchTemplateVersionInput, requestOptions ...request.Option) { + // formatting added to match tags slice during cmp.Equal() + formatTagsInput(arg) + if !cmp.Equal(expectedInput, arg) { + t.Fatalf("mismatch in input expected: %+v, but got %+v, diff: %s", expectedInput, arg, cmp.Diff(expectedInput, arg)) + } + }) + }, + }, { name: "Should return error if AWS failed during launch template version creation", awsResourceReference: []infrav1.AWSResourceReference{{ID: aws.String("1")}}, @@ -1181,7 +1231,6 @@ func TestCreateLaunchTemplateVersion(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { g := NewWithT(t) - scheme, err := setupScheme() g.Expect(err).NotTo(HaveOccurred()) client := fake.NewClientBuilder().WithScheme(scheme).Build() @@ -1189,7 +1238,12 @@ func TestCreateLaunchTemplateVersion(t *testing.T) { cs, err := setupClusterScope(client) g.Expect(err).NotTo(HaveOccurred()) - ms, err := setupMachinePoolScope(client, cs) + var ms *scope.MachinePoolScope + if aws.StringValue(tc.marketType) == ec2.MarketTypeCapacityBlock { + ms, err = setupCapacityBlocksMachinePoolScope(client, cs) + } else { + ms, err = setupMachinePoolScope(client, cs) + } g.Expect(err).NotTo(HaveOccurred()) ms.AWSMachinePool.Spec.AWSLaunchTemplate.AdditionalSecurityGroups = tc.awsResourceReference