diff --git a/api/v1beta1/awscluster_conversion.go b/api/v1beta1/awscluster_conversion.go index bc7f15bf71..4378f19d41 100644 --- a/api/v1beta1/awscluster_conversion.go +++ b/api/v1beta1/awscluster_conversion.go @@ -63,6 +63,7 @@ func (src *AWSCluster) ConvertTo(dstRaw conversion.Hub) error { dst.Status.Bastion.NetworkInterfaceType = restored.Status.Bastion.NetworkInterfaceType dst.Status.Bastion.AssignPrimaryIPv6 = restored.Status.Bastion.AssignPrimaryIPv6 dst.Status.Bastion.CapacityReservationID = restored.Status.Bastion.CapacityReservationID + dst.Status.Bastion.CapacityReservationResourceGroupARN = restored.Status.Bastion.CapacityReservationResourceGroupARN dst.Status.Bastion.MarketType = restored.Status.Bastion.MarketType dst.Status.Bastion.HostAffinity = restored.Status.Bastion.HostAffinity dst.Status.Bastion.HostID = restored.Status.Bastion.HostID diff --git a/api/v1beta1/awsmachine_conversion.go b/api/v1beta1/awsmachine_conversion.go index e6ae62de3e..d09f093f32 100644 --- a/api/v1beta1/awsmachine_conversion.go +++ b/api/v1beta1/awsmachine_conversion.go @@ -43,6 +43,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.CapacityReservationResourceGroupARN = restored.Spec.CapacityReservationResourceGroupARN dst.Spec.MarketType = restored.Spec.MarketType dst.Spec.HostID = restored.Spec.HostID dst.Spec.HostAffinity = restored.Spec.HostAffinity @@ -116,6 +117,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.CapacityReservationResourceGroupARN = restored.Spec.Template.Spec.CapacityReservationResourceGroupARN dst.Spec.Template.Spec.MarketType = restored.Spec.Template.Spec.MarketType dst.Spec.Template.Spec.HostID = restored.Spec.Template.Spec.HostID dst.Spec.Template.Spec.HostAffinity = restored.Spec.Template.Spec.HostAffinity diff --git a/api/v1beta1/zz_generated.conversion.go b/api/v1beta1/zz_generated.conversion.go index 13dc38ae54..f01657baec 100644 --- a/api/v1beta1/zz_generated.conversion.go +++ b/api/v1beta1/zz_generated.conversion.go @@ -1437,6 +1437,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.CapacityReservationResourceGroupARN requires manual conversion: does not exist in peer-type // WARNING: in.MarketType requires manual conversion: does not exist in peer-type // WARNING: in.HostID requires manual conversion: does not exist in peer-type // WARNING: in.HostAffinity requires manual conversion: does not exist in peer-type @@ -2040,6 +2041,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.CapacityReservationResourceGroupARN requires manual conversion: does not exist in peer-type // WARNING: in.MarketType requires manual conversion: does not exist in peer-type // WARNING: in.HostAffinity requires manual conversion: does not exist in peer-type // WARNING: in.HostID requires manual conversion: does not exist in peer-type diff --git a/api/v1beta2/awsmachine_types.go b/api/v1beta2/awsmachine_types.go index 8a6c95fb47..efce8c10d1 100644 --- a/api/v1beta2/awsmachine_types.go +++ b/api/v1beta2/awsmachine_types.go @@ -74,8 +74,9 @@ const ( ) // AWSMachineSpec defines the desired state of an Amazon EC2 instance. -// +kubebuilder:validation:XValidation:rule="!has(self.capacityReservationId) || !has(self.marketType) || self.marketType != 'Spot'",message="capacityReservationId may not be set when marketType is Spot" -// +kubebuilder:validation:XValidation:rule="!has(self.capacityReservationId) || !has(self.spotMarketOptions)",message="capacityReservationId cannot be set when spotMarketOptions is specified" +// +kubebuilder:validation:XValidation:rule="!has(self.capacityReservationId) || !has(self.capacityReservationResourceGroupARN)",message="capacityReservationId and capacityReservationResourceGroupARN are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) || !has(self.marketType) || self.marketType != 'Spot'",message="capacity reservation targets may not be set when marketType is Spot" +// +kubebuilder:validation:XValidation:rule="!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) || !has(self.spotMarketOptions)",message="capacity reservation targets cannot be set when spotMarketOptions is specified" type AWSMachineSpec struct { // ProviderID is the unique identifier as specified by the cloud provider. ProviderID *string `json:"providerID,omitempty"` @@ -246,11 +247,15 @@ type AWSMachineSpec struct { // +optional CapacityReservationID *string `json:"capacityReservationId,omitempty"` + // CapacityReservationResourceGroupARN specifies the ARN of the target Capacity Reservation resource group in which to launch the instance. + // +optional + CapacityReservationResourceGroupARN *string `json:"capacityReservationResourceGroupARN,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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN 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"` diff --git a/api/v1beta2/types.go b/api/v1beta2/types.go index b29b4df5a8..734e496a57 100644 --- a/api/v1beta2/types.go +++ b/api/v1beta2/types.go @@ -271,11 +271,15 @@ type Instance struct { // +optional CapacityReservationID *string `json:"capacityReservationId,omitempty"` + // CapacityReservationResourceGroupARN specifies the ARN of the target Capacity Reservation resource group in which to launch the instance. + // +optional + CapacityReservationResourceGroupARN *string `json:"capacityReservationResourceGroupARN,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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN 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"` @@ -314,6 +318,16 @@ type Instance struct { CPUOptions CPUOptions `json:"cpuOptions,omitempty,omitzero"` } +// HasCapacityReservationTarget returns true when either supported capacity reservation target is set. +func HasCapacityReservationTarget(capacityReservationID, capacityReservationResourceGroupARN *string) bool { + return capacityReservationID != nil || capacityReservationResourceGroupARN != nil +} + +// HasConflictingCapacityReservationTargets returns true when more than one capacity reservation target is set. +func HasConflictingCapacityReservationTargets(capacityReservationID, capacityReservationResourceGroupARN *string) bool { + return capacityReservationID != nil && capacityReservationResourceGroupARN != nil +} + // CapacityReservationPreference describes the preferred use of capacity reservations // of an instance // +kubebuilder:validation:Enum:="";None;CapacityReservationsOnly;Open diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index 4d6c886bb4..3f22ee670b 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -782,6 +782,11 @@ func (in *AWSMachineSpec) DeepCopyInto(out *AWSMachineSpec) { *out = new(string) **out = **in } + if in.CapacityReservationResourceGroupARN != nil { + in, out := &in.CapacityReservationResourceGroupARN, &out.CapacityReservationResourceGroupARN + *out = new(string) + **out = **in + } if in.HostID != nil { in, out := &in.HostID, &out.HostID *out = new(string) @@ -1876,6 +1881,11 @@ func (in *Instance) DeepCopyInto(out *Instance) { *out = new(string) **out = **in } + if in.CapacityReservationResourceGroupARN != nil { + in, out := &in.CapacityReservationResourceGroupARN, &out.CapacityReservationResourceGroupARN + *out = new(string) + **out = **in + } if in.HostAffinity != nil { in, out := &in.HostAffinity, &out.HostAffinity *out = new(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 02d66891bf..18509216ad 100644 --- a/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml +++ b/config/crd/bases/controlplane.cluster.x-k8s.io_awsmanagedcontrolplanes.yaml @@ -1241,6 +1241,11 @@ spec: "None": The instance may not make use of any Capacity Reservations. This is to conserve open reservations for desired workloads "CapacityReservationsOnly": The instance will only run if matched or targeted to a Capacity Reservation. Note that this is incompatible with a MarketType of `Spot` type: string + capacityReservationResourceGroupARN: + description: CapacityReservationResourceGroupARN specifies the + ARN of the target Capacity Reservation resource group in which + to launch the instance. + type: string cpuOptions: description: |- CPUOptions defines CPU-related settings for the instance, including the confidential computing policy. @@ -1405,7 +1410,7 @@ spec: "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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". enum: - OnDemand @@ -3662,6 +3667,11 @@ spec: "None": The instance may not make use of any Capacity Reservations. This is to conserve open reservations for desired workloads "CapacityReservationsOnly": The instance will only run if matched or targeted to a Capacity Reservation. Note that this is incompatible with a MarketType of `Spot` type: string + capacityReservationResourceGroupARN: + description: CapacityReservationResourceGroupARN specifies the + ARN of the target Capacity Reservation resource group in which + to launch the instance. + type: string cpuOptions: description: |- CPUOptions defines CPU-related settings for the instance, including the confidential computing policy. @@ -3826,7 +3836,7 @@ spec: "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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". enum: - OnDemand 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 875b6719e4..4b7f5b563c 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml @@ -2262,6 +2262,11 @@ spec: "None": The instance may not make use of any Capacity Reservations. This is to conserve open reservations for desired workloads "CapacityReservationsOnly": The instance will only run if matched or targeted to a Capacity Reservation. Note that this is incompatible with a MarketType of `Spot` type: string + capacityReservationResourceGroupARN: + description: CapacityReservationResourceGroupARN specifies the + ARN of the target Capacity Reservation resource group in which + to launch the instance. + type: string cpuOptions: description: |- CPUOptions defines CPU-related settings for the instance, including the confidential computing policy. @@ -2426,7 +2431,7 @@ spec: "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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". enum: - OnDemand 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 53373b174b..ae8577fc0e 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinepools.yaml @@ -662,6 +662,11 @@ spec: "None": The instance may not make use of any Capacity Reservations. This is to conserve open reservations for desired workloads "CapacityReservationsOnly": The instance will only run if matched or targeted to a Capacity Reservation type: string + capacityReservationResourceGroupARN: + description: CapacityReservationResourceGroupARN specifies the + ARN of the target Capacity Reservation resource group in which + to launch the instance. + type: string enclaveOptions: description: EnclaveOptions defines the options for Nitro Enclave support on the instance. @@ -782,7 +787,7 @@ spec: "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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". enum: - OnDemand @@ -921,6 +926,18 @@ spec: format: int64 type: integer type: object + x-kubernetes-validations: + - message: capacityReservationId and capacityReservationResourceGroupARN + are mutually exclusive + rule: '!has(self.capacityReservationId) || !has(self.capacityReservationResourceGroupARN)' + - message: capacity reservation targets may not be set when marketType + is Spot + rule: '!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) + || !has(self.marketType) || self.marketType != ''Spot''' + - message: capacity reservation targets cannot be set when spotMarketOptions + is specified + rule: '!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) + || !has(self.spotMarketOptions)' capacityRebalance: description: Enable or disable the capacity rebalance autoscaling group feature 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 1b41ec0c1c..83988abc33 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml @@ -674,6 +674,11 @@ spec: "None": The instance may not make use of any Capacity Reservations. This is to conserve open reservations for desired workloads "CapacityReservationsOnly": The instance will only run if matched or targeted to a Capacity Reservation. Note that this is incompatible with a MarketType of `Spot` type: string + capacityReservationResourceGroupARN: + description: CapacityReservationResourceGroupARN specifies the ARN + of the target Capacity Reservation resource group in which to launch + the instance. + type: string cloudInit: description: |- CloudInit defines options related to the bootstrapping systems where @@ -1015,7 +1020,7 @@ spec: "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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". enum: - OnDemand @@ -1240,12 +1245,17 @@ spec: - instanceType type: object x-kubernetes-validations: - - message: capacityReservationId may not be set when marketType is Spot - rule: '!has(self.capacityReservationId) || !has(self.marketType) || - self.marketType != ''Spot''' - - message: capacityReservationId cannot be set when spotMarketOptions + - message: capacityReservationId and capacityReservationResourceGroupARN + are mutually exclusive + rule: '!has(self.capacityReservationId) || !has(self.capacityReservationResourceGroupARN)' + - message: capacity reservation targets may not be set when marketType + is Spot + rule: '!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) + || !has(self.marketType) || self.marketType != ''Spot''' + - message: capacity reservation targets cannot be set when spotMarketOptions is specified - rule: '!has(self.capacityReservationId) || !has(self.spotMarketOptions)' + rule: '!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) + || !has(self.spotMarketOptions)' status: description: AWSMachineStatus defines the observed state of AWSMachine. properties: 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 07027cae16..15b9e66401 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml @@ -593,6 +593,11 @@ spec: "None": The instance may not make use of any Capacity Reservations. This is to conserve open reservations for desired workloads "CapacityReservationsOnly": The instance will only run if matched or targeted to a Capacity Reservation. Note that this is incompatible with a MarketType of `Spot` type: string + capacityReservationResourceGroupARN: + description: CapacityReservationResourceGroupARN specifies + the ARN of the target Capacity Reservation resource group + in which to launch the instance. + type: string cloudInit: description: |- CloudInit defines options related to the bootstrapping systems where @@ -935,7 +940,7 @@ spec: "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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". enum: - OnDemand @@ -1167,13 +1172,17 @@ spec: - instanceType type: object x-kubernetes-validations: - - message: capacityReservationId may not be set when marketType + - message: capacityReservationId and capacityReservationResourceGroupARN + are mutually exclusive + rule: '!has(self.capacityReservationId) || !has(self.capacityReservationResourceGroupARN)' + - message: capacity reservation targets may not be set when marketType is Spot - rule: '!has(self.capacityReservationId) || !has(self.marketType) - || self.marketType != ''Spot''' - - message: capacityReservationId cannot be set when spotMarketOptions + rule: '!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) + || !has(self.marketType) || self.marketType != ''Spot''' + - message: capacity reservation targets cannot be set when spotMarketOptions is specified - rule: '!has(self.capacityReservationId) || !has(self.spotMarketOptions)' + rule: '!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) + || !has(self.spotMarketOptions)' required: - spec type: object 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 49cf5d19df..7484d13622 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmanagedmachinepools.yaml @@ -671,6 +671,11 @@ spec: "None": The instance may not make use of any Capacity Reservations. This is to conserve open reservations for desired workloads "CapacityReservationsOnly": The instance will only run if matched or targeted to a Capacity Reservation type: string + capacityReservationResourceGroupARN: + description: CapacityReservationResourceGroupARN specifies the + ARN of the target Capacity Reservation resource group in which + to launch the instance. + type: string enclaveOptions: description: EnclaveOptions defines the options for Nitro Enclave support on the instance. @@ -791,7 +796,7 @@ spec: "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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to "Spot". enum: - OnDemand @@ -930,6 +935,18 @@ spec: format: int64 type: integer type: object + x-kubernetes-validations: + - message: capacityReservationId and capacityReservationResourceGroupARN + are mutually exclusive + rule: '!has(self.capacityReservationId) || !has(self.capacityReservationResourceGroupARN)' + - message: capacity reservation targets may not be set when marketType + is Spot + rule: '!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) + || !has(self.marketType) || self.marketType != ''Spot''' + - message: capacity reservation targets cannot be set when spotMarketOptions + is specified + rule: '!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) + || !has(self.spotMarketOptions)' capacityType: default: onDemand description: CapacityType specifies the capacity type for the ASG diff --git a/docs/book/src/crd/index.md b/docs/book/src/crd/index.md index 3f54810d10..399acae676 100644 --- a/docs/book/src/crd/index.md +++ b/docs/book/src/crd/index.md @@ -22329,6 +22329,18 @@ string +capacityReservationResourceGroupARN
+ +string + + + +(Optional) +

CapacityReservationResourceGroupARN specifies the ARN of the target Capacity Reservation resource group in which to launch the instance.

+ + + + marketType
@@ -22342,7 +22354,7 @@ MarketType “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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to “Spot”.

@@ -22887,6 +22899,18 @@ string +capacityReservationResourceGroupARN
+ +string + + + +(Optional) +

CapacityReservationResourceGroupARN specifies the ARN of the target Capacity Reservation resource group in which to launch the instance.

+ + + + marketType
@@ -22900,7 +22924,7 @@ MarketType “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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to “Spot”.

@@ -23668,6 +23692,18 @@ string +capacityReservationResourceGroupARN
+ +string + + + +(Optional) +

CapacityReservationResourceGroupARN specifies the ARN of the target Capacity Reservation resource group in which to launch the instance.

+ + + + marketType
@@ -23681,7 +23717,7 @@ MarketType “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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to “Spot”.

@@ -26184,6 +26220,18 @@ string +capacityReservationResourceGroupARN
+ +string + + + +(Optional) +

CapacityReservationResourceGroupARN specifies the ARN of the target Capacity Reservation resource group in which to launch the instance.

+ + + + marketType
@@ -26197,7 +26245,7 @@ MarketType “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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to “Spot”.

@@ -28629,6 +28677,18 @@ string +capacityReservationResourceGroupARN
+ +string + + + +(Optional) +

CapacityReservationResourceGroupARN specifies the ARN of the target Capacity Reservation resource group in which to launch the instance.

+ + + + marketType
@@ -28642,7 +28702,7 @@ MarketType “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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN must be specified to identify the target reservation. If marketType is not specified and spotMarketOptions is provided, the marketType defaults to “Spot”.

diff --git a/exp/api/v1beta1/conversion.go b/exp/api/v1beta1/conversion.go index b943c6d417..801f1fb678 100644 --- a/exp/api/v1beta1/conversion.go +++ b/exp/api/v1beta1/conversion.go @@ -68,6 +68,9 @@ func (src *AWSMachinePool) ConvertTo(dstRaw conversion.Hub) error { if restored.Spec.AWSLaunchTemplate.CapacityReservationID != nil { dst.Spec.AWSLaunchTemplate.CapacityReservationID = restored.Spec.AWSLaunchTemplate.CapacityReservationID } + if restored.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN != nil { + dst.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN = restored.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN + } if restored.Spec.AWSLaunchTemplate.MarketType != "" { dst.Spec.AWSLaunchTemplate.MarketType = restored.Spec.AWSLaunchTemplate.MarketType @@ -135,6 +138,9 @@ func (src *AWSManagedMachinePool) ConvertTo(dstRaw conversion.Hub) error { if restored.Spec.AWSLaunchTemplate.CapacityReservationID != nil { dst.Spec.AWSLaunchTemplate.CapacityReservationID = restored.Spec.AWSLaunchTemplate.CapacityReservationID } + if restored.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN != nil { + dst.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN = restored.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN + } if restored.Spec.AWSLaunchTemplate.MarketType != "" { dst.Spec.AWSLaunchTemplate.MarketType = restored.Spec.AWSLaunchTemplate.MarketType diff --git a/exp/api/v1beta1/zz_generated.conversion.go b/exp/api/v1beta1/zz_generated.conversion.go index 612dc28083..a81362311a 100644 --- a/exp/api/v1beta1/zz_generated.conversion.go +++ b/exp/api/v1beta1/zz_generated.conversion.go @@ -414,6 +414,7 @@ func autoConvert_v1beta2_AWSLaunchTemplate_To_v1beta1_AWSLaunchTemplate(in *v1be // WARNING: in.EnclaveOptions 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.CapacityReservationResourceGroupARN requires manual conversion: does not exist in peer-type // WARNING: in.MarketType requires manual conversion: does not exist in peer-type // WARNING: in.CapacityReservationPreference requires manual conversion: does not exist in peer-type return nil diff --git a/exp/api/v1beta2/types.go b/exp/api/v1beta2/types.go index 0bc21ea33d..db45447387 100644 --- a/exp/api/v1beta2/types.go +++ b/exp/api/v1beta2/types.go @@ -60,6 +60,9 @@ type BlockDeviceMapping struct { } // AWSLaunchTemplate defines the desired state of AWSLaunchTemplate. +// +kubebuilder:validation:XValidation:rule="!has(self.capacityReservationId) || !has(self.capacityReservationResourceGroupARN)",message="capacityReservationId and capacityReservationResourceGroupARN are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) || !has(self.marketType) || self.marketType != 'Spot'",message="capacity reservation targets may not be set when marketType is Spot" +// +kubebuilder:validation:XValidation:rule="!(has(self.capacityReservationId) || has(self.capacityReservationResourceGroupARN)) || !has(self.spotMarketOptions)",message="capacity reservation targets cannot be set when spotMarketOptions is specified" type AWSLaunchTemplate struct { // The name of the launch template. Name string `json:"name,omitempty"` @@ -142,11 +145,15 @@ type AWSLaunchTemplate struct { // +optional CapacityReservationID *string `json:"capacityReservationId,omitempty"` + // CapacityReservationResourceGroupARN specifies the ARN of the target Capacity Reservation resource group in which to launch the instance. + // +optional + CapacityReservationResourceGroupARN *string `json:"capacityReservationResourceGroupARN,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 this value is selected, either CapacityReservationID or CapacityReservationResourceGroupARN 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"` diff --git a/exp/api/v1beta2/zz_generated.deepcopy.go b/exp/api/v1beta2/zz_generated.deepcopy.go index 2e6b638777..6a699b3552 100644 --- a/exp/api/v1beta2/zz_generated.deepcopy.go +++ b/exp/api/v1beta2/zz_generated.deepcopy.go @@ -147,6 +147,11 @@ func (in *AWSLaunchTemplate) DeepCopyInto(out *AWSLaunchTemplate) { *out = new(string) **out = **in } + if in.CapacityReservationResourceGroupARN != nil { + in, out := &in.CapacityReservationResourceGroupARN, &out.CapacityReservationResourceGroupARN + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSLaunchTemplate. diff --git a/exp/webhooks/awsmachinepool_webhook.go b/exp/webhooks/awsmachinepool_webhook.go index e54d602d33..37d0c220e8 100644 --- a/exp/webhooks/awsmachinepool_webhook.go +++ b/exp/webhooks/awsmachinepool_webhook.go @@ -229,10 +229,14 @@ func (w *AWSMachinePool) ValidateCreate(_ context.Context, obj runtime.Object) ( func (w *AWSMachinePool) validateCapacityReservation(r *expinfrav1.AWSMachinePool) field.ErrorList { var allErrs field.ErrorList - if r.Spec.AWSLaunchTemplate.CapacityReservationID != nil && + hasCapacityReservationTarget := infrav1.HasCapacityReservationTarget(r.Spec.AWSLaunchTemplate.CapacityReservationID, r.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN) + if infrav1.HasConflictingCapacityReservationTargets(r.Spec.AWSLaunchTemplate.CapacityReservationID, r.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN) { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "awsLaunchTemplate", "capacityReservationResourceGroupARN"), "capacityReservationId and capacityReservationResourceGroupARN are mutually exclusive")) + } + if hasCapacityReservationTarget && r.Spec.AWSLaunchTemplate.CapacityReservationPreference != infrav1.CapacityReservationPreferenceOnly && r.Spec.AWSLaunchTemplate.CapacityReservationPreference != "" { - allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "capacityReservationPreference"), "when capacityReservationId is specified, capacityReservationPreference may only be `CapacityReservationsOnly` or empty")) + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "awsLaunchTemplate", "capacityReservationPreference"), "when a capacity reservation target is specified, capacityReservationPreference may only be `CapacityReservationsOnly` or empty")) } return allErrs } @@ -246,20 +250,22 @@ func (w *AWSMachinePool) validateInstanceMarketType(r *expinfrav1.AWSMachinePool 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 == infrav1.MarketTypeCapacityBlock && r.Spec.AWSLaunchTemplate.CapacityReservationID == nil { - allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.capacityReservationID"), "is required when CapacityBlock is provided")) + if r.Spec.AWSLaunchTemplate.MarketType == infrav1.MarketTypeCapacityBlock && + !infrav1.HasCapacityReservationTarget(r.Spec.AWSLaunchTemplate.CapacityReservationID, r.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN) { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.capacityReservationID"), "capacityReservationID or capacityReservationResourceGroupARN is required when CapacityBlock is provided")) } switch r.Spec.AWSLaunchTemplate.MarketType { case "", infrav1.MarketTypeOnDemand, infrav1.MarketTypeSpot, infrav1.MarketTypeCapacityBlock: default: allErrs = append(allErrs, field.Invalid(field.NewPath("spec.awsLaunchTemplate.marketType"), r.Spec.AWSLaunchTemplate.MarketType, fmt.Sprintf("Valid values are: %s, %s, %s and omitted", infrav1.MarketTypeOnDemand, infrav1.MarketTypeSpot, infrav1.MarketTypeCapacityBlock))) } - if r.Spec.AWSLaunchTemplate.MarketType == infrav1.MarketTypeSpot && r.Spec.AWSLaunchTemplate.CapacityReservationID != nil { - allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.marketType"), "cannot be set to 'Spot' when CapacityReservationID is specified")) + if r.Spec.AWSLaunchTemplate.MarketType == infrav1.MarketTypeSpot && + infrav1.HasCapacityReservationTarget(r.Spec.AWSLaunchTemplate.CapacityReservationID, r.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN) { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.marketType"), "cannot be set to 'Spot' when a capacity reservation target is specified")) } - if r.Spec.AWSLaunchTemplate.CapacityReservationID != nil && r.Spec.AWSLaunchTemplate.SpotMarketOptions != nil { - allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.spotMarketOptions"), "cannot be set to when CapacityReservationID is specified")) + if infrav1.HasCapacityReservationTarget(r.Spec.AWSLaunchTemplate.CapacityReservationID, r.Spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN) && r.Spec.AWSLaunchTemplate.SpotMarketOptions != nil { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec.awsLaunchTemplate.spotMarketOptions"), "cannot be set when a capacity reservation target is specified")) } return allErrs @@ -280,6 +286,8 @@ func (w *AWSMachinePool) ValidateUpdate(_ context.Context, oldObj, newObj runtim allErrs = append(allErrs, w.validateAdditionalSecurityGroups(r)...) allErrs = append(allErrs, w.validateSpotInstances(r)...) allErrs = append(allErrs, w.validateRefreshPreferences(r)...) + allErrs = append(allErrs, w.validateInstanceMarketType(r)...) + allErrs = append(allErrs, w.validateCapacityReservation(r)...) allErrs = append(allErrs, w.validateLifecycleHooks(r)...) if len(allErrs) == 0 { diff --git a/exp/webhooks/awsmachinepool_webhook_test.go b/exp/webhooks/awsmachinepool_webhook_test.go index cd62e047f4..4d3bd0978f 100644 --- a/exp/webhooks/awsmachinepool_webhook_test.go +++ b/exp/webhooks/awsmachinepool_webhook_test.go @@ -290,7 +290,19 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErrToContain: ptr.To("cannot be set to 'Spot' when CapacityReservationID is specified"), + wantErrToContain: ptr.To("cannot be set to 'Spot' when a capacity reservation target is specified"), + }, + { + name: "with MarketType Spot and CapacityReservationResourceGroupARN value provided", + pool: &expinfrav1.AWSMachinePool{ + Spec: expinfrav1.AWSMachinePoolSpec{ + AWSLaunchTemplate: expinfrav1.AWSLaunchTemplate{ + MarketType: infrav1.MarketTypeSpot, + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + }, + }, + wantErrToContain: ptr.To("cannot be set to 'Spot' when a capacity reservation target is specified"), }, { name: "with CapacityReservationID and SpotMarketOptions value provided", @@ -302,7 +314,19 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErrToContain: ptr.To("cannot be set to when CapacityReservationID is specified"), + wantErrToContain: ptr.To("cannot be set when a capacity reservation target is specified"), + }, + { + name: "with CapacityReservationResourceGroupARN and SpotMarketOptions value provided", + pool: &expinfrav1.AWSMachinePool{ + Spec: expinfrav1.AWSMachinePoolSpec{ + AWSLaunchTemplate: expinfrav1.AWSLaunchTemplate{ + SpotMarketOptions: &infrav1.SpotMarketOptions{}, + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + }, + }, + wantErrToContain: ptr.To("cannot be set when a capacity reservation target is specified"), }, { name: "with CapacityReservationPreference of `none` and CapacityReservationID is specified", @@ -314,7 +338,19 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErrToContain: ptr.To("when capacityReservationId is specified, capacityReservationPreference may only be `CapacityReservationsOnly` or empty"), + wantErrToContain: ptr.To("when a capacity reservation target is specified, capacityReservationPreference may only be `CapacityReservationsOnly` or empty"), + }, + { + name: "with CapacityReservationID and CapacityReservationResourceGroupARN values provided", + pool: &expinfrav1.AWSMachinePool{ + Spec: expinfrav1.AWSMachinePoolSpec{ + AWSLaunchTemplate: expinfrav1.AWSLaunchTemplate{ + CapacityReservationID: aws.String("cr-123"), + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + }, + }, + wantErrToContain: ptr.To("capacityReservationId and capacityReservationResourceGroupARN are mutually exclusive"), }, { name: "invalid, MarketType set to MarketTypeCapacityBlock and spotMarketOptions are specified", @@ -349,7 +385,7 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, }, }, - wantErrToContain: ptr.To[string]("capacityReservationID: Forbidden: is required when CapacityBlock is provided"), + wantErrToContain: ptr.To[string]("capacityReservationID: Forbidden: capacityReservationID or capacityReservationResourceGroupARN is required when CapacityBlock is provided"), }, { name: "valid MarketType set to MarketTypeCapacityBlock and CapacityReservationId are specified", @@ -363,6 +399,18 @@ func TestAWSMachinePoolValidateCreate(t *testing.T) { }, wantErrToContain: nil, }, + { + name: "valid MarketType set to MarketTypeCapacityBlock and CapacityReservationResourceGroupARN are specified", + pool: &expinfrav1.AWSMachinePool{ + Spec: expinfrav1.AWSMachinePoolSpec{ + AWSLaunchTemplate: expinfrav1.AWSLaunchTemplate{ + MarketType: infrav1.MarketTypeCapacityBlock, + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + }, + }, + wantErrToContain: nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -500,6 +548,44 @@ func TestAWSMachinePoolValidateUpdate(t *testing.T) { }, wantErrToContain: ptr.To[string]("spotMarketOptions"), }, + { + name: "Should fail update if CapacityReservationID and CapacityReservationResourceGroupARN are both set", + old: &expinfrav1.AWSMachinePool{}, + new: &expinfrav1.AWSMachinePool{ + Spec: expinfrav1.AWSMachinePoolSpec{ + AWSLaunchTemplate: expinfrav1.AWSLaunchTemplate{ + CapacityReservationID: aws.String("cr-123"), + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + }, + }, + wantErrToContain: ptr.To[string]("capacityReservationId and capacityReservationResourceGroupARN are mutually exclusive"), + }, + { + name: "Should fail update if Spot market type is used with a capacity reservation target", + old: &expinfrav1.AWSMachinePool{}, + new: &expinfrav1.AWSMachinePool{ + Spec: expinfrav1.AWSMachinePoolSpec{ + AWSLaunchTemplate: expinfrav1.AWSLaunchTemplate{ + MarketType: infrav1.MarketTypeSpot, + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + }, + }, + wantErrToContain: ptr.To[string]("cannot be set to 'Spot' when a capacity reservation target is specified"), + }, + { + name: "Should fail update if CapacityBlock is used without a capacity reservation target", + old: &expinfrav1.AWSMachinePool{}, + new: &expinfrav1.AWSMachinePool{ + Spec: expinfrav1.AWSMachinePoolSpec{ + AWSLaunchTemplate: expinfrav1.AWSLaunchTemplate{ + MarketType: infrav1.MarketTypeCapacityBlock, + }, + }, + }, + wantErrToContain: ptr.To[string]("capacityReservationID or capacityReservationResourceGroupARN is required when CapacityBlock is provided"), + }, { name: "Should fail if MaxHealthyPercentage is set, but MinHealthyPercentage is not set", new: &expinfrav1.AWSMachinePool{ diff --git a/pkg/cloud/services/ec2/instances.go b/pkg/cloud/services/ec2/instances.go index d2dce68d0e..fcd47933cc 100644 --- a/pkg/cloud/services/ec2/instances.go +++ b/pkg/cloud/services/ec2/instances.go @@ -257,6 +257,7 @@ func (s *Service) CreateInstance(ctx context.Context, scope *scope.MachineScope, input.PrivateDNSName = scope.AWSMachine.Spec.PrivateDNSName input.CapacityReservationID = scope.AWSMachine.Spec.CapacityReservationID + input.CapacityReservationResourceGroupARN = scope.AWSMachine.Spec.CapacityReservationResourceGroupARN input.MarketType = scope.AWSMachine.Spec.MarketType @@ -697,7 +698,11 @@ func (s *Service) runInstance(role string, i *infrav1.Instance) (*infrav1.Instan } input.MetadataOptions = getInstanceMetadataOptionsRequest(i.InstanceMetadataOptions) input.PrivateDnsNameOptions = getPrivateDNSNameOptionsRequest(i.PrivateDNSName) - input.CapacityReservationSpecification = getCapacityReservationSpecification(i.CapacityReservationID, i.CapacityReservationPreference) + capacityReservationSpecification, err := getCapacityReservationSpecification(i.CapacityReservationID, i.CapacityReservationResourceGroupARN, i.CapacityReservationPreference) + if err != nil { + return nil, err + } + input.CapacityReservationSpecification = capacityReservationSpecification input.CpuOptions = getInstanceCPUOptionsRequest(i.CPUOptions) if i.Tenancy != "" { @@ -1242,18 +1247,26 @@ func filterGroups(list []string, strToFilter string) (newList []string) { return } -func getCapacityReservationSpecification(capacityReservationID *string, capacityReservationPreference infrav1.CapacityReservationPreference) *types.CapacityReservationSpecification { - if capacityReservationID == nil && capacityReservationPreference == "" { - return nil +func getCapacityReservationSpecification(capacityReservationID, capacityReservationResourceGroupARN *string, capacityReservationPreference infrav1.CapacityReservationPreference) (*types.CapacityReservationSpecification, error) { + if infrav1.HasConflictingCapacityReservationTargets(capacityReservationID, capacityReservationResourceGroupARN) { + return nil, errors.New("capacityReservationID and capacityReservationResourceGroupARN are mutually exclusive") + } + + if !infrav1.HasCapacityReservationTarget(capacityReservationID, capacityReservationResourceGroupARN) && capacityReservationPreference == "" { + return nil, nil } var spec types.CapacityReservationSpecification if capacityReservationID != nil { spec.CapacityReservationTarget = &types.CapacityReservationTarget{ CapacityReservationId: capacityReservationID, } + } else if capacityReservationResourceGroupARN != nil { + spec.CapacityReservationTarget = &types.CapacityReservationTarget{ + CapacityReservationResourceGroupArn: capacityReservationResourceGroupARN, + } } spec.CapacityReservationPreference = CapacityReservationPreferenceToSDK(capacityReservationPreference) - return &spec + return &spec, nil } func getInstanceMarketOptionsRequest(i *infrav1.Instance) (*types.InstanceMarketOptionsRequest, error) { @@ -1261,8 +1274,13 @@ func getInstanceMarketOptionsRequest(i *infrav1.Instance) (*types.InstanceMarket return nil, errors.New("can't create spot capacity-blocks, remove spot market request") } - if (i.MarketType == infrav1.MarketTypeSpot || i.SpotMarketOptions != nil) && i.CapacityReservationID != nil { - return nil, errors.New("unable to generate marketOptions for spot instance, capacityReservationID is incompatible with marketType spot and spotMarketOptions") + hasCapacityReservationTarget := infrav1.HasCapacityReservationTarget(i.CapacityReservationID, i.CapacityReservationResourceGroupARN) + if infrav1.HasConflictingCapacityReservationTargets(i.CapacityReservationID, i.CapacityReservationResourceGroupARN) { + return nil, errors.New("capacityReservationID and capacityReservationResourceGroupARN are mutually exclusive") + } + + if (i.MarketType == infrav1.MarketTypeSpot || i.SpotMarketOptions != nil) && hasCapacityReservationTarget { + return nil, errors.New("unable to generate marketOptions for spot instance, capacity reservation targets are incompatible with marketType spot and spotMarketOptions") } // Infer MarketType if not explicitly set @@ -1280,8 +1298,8 @@ func getInstanceMarketOptionsRequest(i *infrav1.Instance) (*types.InstanceMarket switch i.MarketType { case infrav1.MarketTypeCapacityBlock: - if i.CapacityReservationID == nil { - return nil, errors.Errorf("capacityReservationID is required when CapacityBlock is enabled") + if !hasCapacityReservationTarget { + return nil, errors.Errorf("capacityReservationID or capacityReservationResourceGroupARN is required when CapacityBlock is enabled") } return &types.InstanceMarketOptionsRequest{ MarketType: types.MarketTypeCapacityBlock, diff --git a/pkg/cloud/services/ec2/instances_test.go b/pkg/cloud/services/ec2/instances_test.go index b786d50b00..2f0b792848 100644 --- a/pkg/cloud/services/ec2/instances_test.go +++ b/pkg/cloud/services/ec2/instances_test.go @@ -5528,7 +5528,7 @@ func TestCreateInstance(t *testing.T) { }, nil) }, check: func(instance *infrav1.Instance, err error) { - expectedErrMsg := "capacityReservationID is required when CapacityBlock is enabled" + expectedErrMsg := "capacityReservationID or capacityReservationResourceGroupARN is required when CapacityBlock is enabled" if err == nil { t.Fatalf("Expected error, but got nil") } @@ -5779,6 +5779,140 @@ func TestCreateInstance(t *testing.T) { } }, }, + { + name: "Simple, setting CapacityReservationResourceGroupARN and CapacityReservationPreference", + 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", + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + CapacityReservationPreference: infrav1.CapacityReservationPreferenceOnly, + }, + 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. + DescribeInstanceTypes(context.TODO(), gomock.Eq(&ec2.DescribeInstanceTypesInput{ + InstanceTypes: []types.InstanceType{ + types.InstanceTypeM5Large, + }, + })). + Return(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []types.InstanceTypeInfo{ + { + ProcessorInfo: &types.ProcessorInfo{ + SupportedArchitectures: []types.ArchitectureType{ + types.ArchitectureTypeX8664, + }, + }, + }, + }, + }, nil) + m. + RunInstances(context.TODO(), gomock.Any()). + DoAndReturn(func(ctx context.Context, input *ec2.RunInstancesInput, optFns ...func(*ec2.Options)) (*ec2.RunInstancesOutput, error) { + if input.CapacityReservationSpecification == nil || + input.CapacityReservationSpecification.CapacityReservationTarget == nil || + aws.ToString(input.CapacityReservationSpecification.CapacityReservationTarget.CapacityReservationResourceGroupArn) != "arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group" || + input.CapacityReservationSpecification.CapacityReservationPreference != types.CapacityReservationPreferenceCapacityReservationsOnly { + t.Fatalf("unexpected capacity reservation specification: %#v", input.CapacityReservationSpecification) + } + + return &ec2.RunInstancesOutput{ + Instances: []types.Instance{ + { + State: &types.InstanceState{ + Name: types.InstanceStateNamePending, + }, + IamInstanceProfile: &types.IamInstanceProfile{ + Arn: aws.String("arn:aws:iam::123456789012:instance-profile/foo"), + }, + InstanceId: aws.String("two"), + InstanceType: types.InstanceTypeM5Large, + SubnetId: aws.String("subnet-1"), + ImageId: aws.String("ami-1"), + RootDeviceName: aws.String("device-1"), + BlockDeviceMappings: []types.InstanceBlockDeviceMapping{ + { + DeviceName: aws.String("device-1"), + Ebs: &types.EbsInstanceBlockDevice{ + VolumeId: aws.String("volume-1"), + }, + }, + }, + Placement: &types.Placement{ + AvailabilityZone: &az, + }, + CapacityReservationSpecification: &types.CapacityReservationSpecificationResponse{ + CapacityReservationPreference: types.CapacityReservationPreferenceCapacityReservationsOnly, + CapacityReservationTarget: &types.CapacityReservationTargetResponse{ + CapacityReservationResourceGroupArn: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + }, + InstanceLifecycle: types.InstanceLifecycleTypeScheduled, + }, + }, + }, nil + }) + m. + DescribeNetworkInterfaces(context.TODO(), gomock.Any()). + Return(&ec2.DescribeNetworkInterfacesOutput{ + NetworkInterfaces: []types.NetworkInterface{}, + NextToken: nil, + }, nil) + }, + check: func(instance *infrav1.Instance, err error) { + if err != nil { + t.Fatalf("did not expect error: %v", err) + } + }, + }, { name: "with AMD SEV-SNP enabled", machine: &clusterv1.Machine{ @@ -6108,6 +6242,7 @@ func TestCreateInstance(t *testing.T) { func TestGetInstanceMarketOptionsRequest(t *testing.T) { mockCapacityReservationID := ptr.To[string]("cr-123") + mockCapacityReservationResourceGroupARN := ptr.To[string]("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group") testCases := []struct { name string instance *infrav1.Instance @@ -6169,7 +6304,7 @@ func TestGetInstanceMarketOptionsRequest(t *testing.T) { MarketType: infrav1.MarketTypeSpot, CapacityReservationID: mockCapacityReservationID, }, - expectedError: errors.Errorf("unable to generate marketOptions for spot instance, capacityReservationID is incompatible with marketType spot and spotMarketOptions"), + expectedError: errors.Errorf("unable to generate marketOptions for spot instance, capacity reservation targets are incompatible with marketType spot and spotMarketOptions"), }, { name: "with spotMarketOptions and capacityRerservationID specified", @@ -6177,7 +6312,23 @@ func TestGetInstanceMarketOptionsRequest(t *testing.T) { SpotMarketOptions: &infrav1.SpotMarketOptions{}, CapacityReservationID: mockCapacityReservationID, }, - expectedError: errors.Errorf("unable to generate marketOptions for spot instance, capacityReservationID is incompatible with marketType spot and spotMarketOptions"), + expectedError: errors.Errorf("unable to generate marketOptions for spot instance, capacity reservation targets are incompatible with marketType spot and spotMarketOptions"), + }, + { + name: "with marketType Spot and capacityReservationResourceGroupARN specified", + instance: &infrav1.Instance{ + MarketType: infrav1.MarketTypeSpot, + CapacityReservationResourceGroupARN: mockCapacityReservationResourceGroupARN, + }, + expectedError: errors.Errorf("unable to generate marketOptions for spot instance, capacity reservation targets are incompatible with marketType spot and spotMarketOptions"), + }, + { + name: "with capacityReservationID and capacityReservationResourceGroupARN specified", + instance: &infrav1.Instance{ + CapacityReservationID: mockCapacityReservationID, + CapacityReservationResourceGroupARN: mockCapacityReservationResourceGroupARN, + }, + expectedError: errors.Errorf("capacityReservationID and capacityReservationResourceGroupARN are mutually exclusive"), }, { name: "with an empty MaxPrice specified", @@ -6225,7 +6376,7 @@ func TestGetInstanceMarketOptionsRequest(t *testing.T) { CapacityReservationID: nil, }, expectedRequest: nil, - expectedError: errors.Errorf("capacityReservationID is required when CapacityBlock is enabled"), + expectedError: errors.Errorf("capacityReservationID or capacityReservationResourceGroupARN is required when CapacityBlock is enabled"), }, { name: "with a MarketType to MarketTypeCapacityBlock with capacityReservationID set to nil", @@ -6238,6 +6389,17 @@ func TestGetInstanceMarketOptionsRequest(t *testing.T) { }, expectedError: nil, }, + { + name: "with a MarketType to MarketTypeCapacityBlock with capacityReservationResourceGroupARN set", + instance: &infrav1.Instance{ + MarketType: infrav1.MarketTypeCapacityBlock, + CapacityReservationResourceGroupARN: mockCapacityReservationResourceGroupARN, + }, + expectedRequest: &types.InstanceMarketOptionsRequest{ + MarketType: types.MarketTypeCapacityBlock, + }, + expectedError: nil, + }, { name: "with a MarketType to MarketTypeCapacityBlock set with capacityReservationID set and empty Spot options specified", instance: &infrav1.Instance{ @@ -6822,11 +6984,15 @@ func mockedGetPrivateDNSDomainNameFromDHCPOptionsErrorCalls(m *mocks.MockEC2APIM func TestGetCapacityReservationSpecification(t *testing.T) { mockCapacityReservationID := "cr-123" mockCapacityReservationIDPtr := &mockCapacityReservationID + mockCapacityReservationResourceGroupARN := "arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group" + mockCapacityReservationResourceGroupARNPtr := &mockCapacityReservationResourceGroupARN testCases := []struct { - name string - capacityReservationID *string - capacityReservationPreference infrav1.CapacityReservationPreference - expectedRequest *types.CapacityReservationSpecification + name string + capacityReservationID *string + capacityReservationResourceGroupARN *string + capacityReservationPreference infrav1.CapacityReservationPreference + expectedRequest *types.CapacityReservationSpecification + expectedError error }{ { name: "with no CapacityReservationID options specified", @@ -6842,6 +7008,26 @@ func TestGetCapacityReservationSpecification(t *testing.T) { }, }, }, + { + name: "with a valid reservation resource group ARN specified", + capacityReservationResourceGroupARN: mockCapacityReservationResourceGroupARNPtr, + expectedRequest: &types.CapacityReservationSpecification{ + CapacityReservationTarget: &types.CapacityReservationTarget{ + CapacityReservationResourceGroupArn: aws.String(mockCapacityReservationResourceGroupARN), + }, + }, + }, + { + name: "with a valid reservation resource group ARN and a preference", + capacityReservationResourceGroupARN: mockCapacityReservationResourceGroupARNPtr, + capacityReservationPreference: infrav1.CapacityReservationPreferenceOnly, + expectedRequest: &types.CapacityReservationSpecification{ + CapacityReservationTarget: &types.CapacityReservationTarget{ + CapacityReservationResourceGroupArn: aws.String(mockCapacityReservationResourceGroupARN), + }, + CapacityReservationPreference: types.CapacityReservationPreferenceCapacityReservationsOnly, + }, + }, { name: "with a valid reservation ID and a preference", capacityReservationID: mockCapacityReservationIDPtr, @@ -6861,10 +7047,27 @@ func TestGetCapacityReservationSpecification(t *testing.T) { CapacityReservationPreference: types.CapacityReservationPreferenceNone, }, }, + { + name: "with both reservation target types", + capacityReservationID: mockCapacityReservationIDPtr, + capacityReservationResourceGroupARN: mockCapacityReservationResourceGroupARNPtr, + expectedError: errors.New("capacityReservationID and capacityReservationResourceGroupARN are mutually exclusive"), + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - request := getCapacityReservationSpecification(tc.capacityReservationID, tc.capacityReservationPreference) + request, err := getCapacityReservationSpecification(tc.capacityReservationID, tc.capacityReservationResourceGroupARN, tc.capacityReservationPreference) + if tc.expectedError == nil && err != nil { + t.Fatalf("Case: %s. Got error: %v, expected: %v", tc.name, err, tc.expectedError) + } + if tc.expectedError != nil { + if err == nil { + t.Fatalf("Case: %s. Got error: %v, expected: %v", tc.name, err, tc.expectedError) + } + if err.Error() != tc.expectedError.Error() { + t.Fatalf("Case: %s. Got error: %v, expected: %v", tc.name, err, tc.expectedError) + } + } if !cmp.Equal(request, tc.expectedRequest, cmpopts.IgnoreUnexported(types.CapacityReservationSpecification{}, types.CapacityReservationTarget{})) { t.Errorf("Case: %s. Got: %v, expected: %v", tc.name, request, tc.expectedRequest) } diff --git a/pkg/cloud/services/ec2/launchtemplate.go b/pkg/cloud/services/ec2/launchtemplate.go index c67ad0de60..6f4e738b4c 100644 --- a/pkg/cloud/services/ec2/launchtemplate.go +++ b/pkg/cloud/services/ec2/launchtemplate.go @@ -679,7 +679,11 @@ func (s *Service) createLaunchTemplateData(scope scope.LaunchTemplateScope, imag } data.InstanceMarketOptions = instanceMarketOptions data.PrivateDnsNameOptions = getLaunchTemplatePrivateDNSNameOptionsRequest(scope.GetLaunchTemplate().PrivateDNSName) - data.CapacityReservationSpecification = getLaunchTemplateCapacityReservationSpecification(scope.GetLaunchTemplate()) + capacityReservationSpecification, err := getLaunchTemplateCapacityReservationSpecification(scope.GetLaunchTemplate()) + if err != nil { + return nil, err + } + data.CapacityReservationSpecification = capacityReservationSpecification blockDeviceMappings := []types.LaunchTemplateBlockDeviceMappingRequest{} @@ -712,12 +716,15 @@ func (s *Service) createLaunchTemplateData(scope scope.LaunchTemplateScope, imag return data, nil } -func getLaunchTemplateCapacityReservationSpecification(awsLaunchTemplate *expinfrav1.AWSLaunchTemplate) *types.LaunchTemplateCapacityReservationSpecificationRequest { +func getLaunchTemplateCapacityReservationSpecification(awsLaunchTemplate *expinfrav1.AWSLaunchTemplate) (*types.LaunchTemplateCapacityReservationSpecificationRequest, error) { if awsLaunchTemplate == nil { - return nil + return nil, nil } - if awsLaunchTemplate.CapacityReservationID == nil && awsLaunchTemplate.CapacityReservationPreference == "" { - return nil + if infrav1.HasConflictingCapacityReservationTargets(awsLaunchTemplate.CapacityReservationID, awsLaunchTemplate.CapacityReservationResourceGroupARN) { + return nil, errors.New("capacityReservationID and capacityReservationResourceGroupARN are mutually exclusive") + } + if !infrav1.HasCapacityReservationTarget(awsLaunchTemplate.CapacityReservationID, awsLaunchTemplate.CapacityReservationResourceGroupARN) && awsLaunchTemplate.CapacityReservationPreference == "" { + return nil, nil } spec := &types.LaunchTemplateCapacityReservationSpecificationRequest{ CapacityReservationPreference: CapacityReservationPreferenceToSDK(awsLaunchTemplate.CapacityReservationPreference), @@ -726,8 +733,12 @@ func getLaunchTemplateCapacityReservationSpecification(awsLaunchTemplate *expinf spec.CapacityReservationTarget = &types.CapacityReservationTarget{ CapacityReservationId: awsLaunchTemplate.CapacityReservationID, } + } else if awsLaunchTemplate.CapacityReservationResourceGroupARN != nil { + spec.CapacityReservationTarget = &types.CapacityReservationTarget{ + CapacityReservationResourceGroupArn: awsLaunchTemplate.CapacityReservationResourceGroupARN, + } } - return spec + return spec, nil } func volumeToLaunchTemplateBlockDeviceMappingRequest(v *infrav1.Volume) *types.LaunchTemplateBlockDeviceMappingRequest { @@ -926,6 +937,14 @@ func (s *Service) SDKToLaunchTemplate(d types.LaunchTemplateVersion) (*expinfrav v.CapacityReservationSpecification.CapacityReservationTarget.CapacityReservationId != nil { i.CapacityReservationID = v.CapacityReservationSpecification.CapacityReservationTarget.CapacityReservationId } + if v.CapacityReservationSpecification != nil && + v.CapacityReservationSpecification.CapacityReservationTarget != nil && + v.CapacityReservationSpecification.CapacityReservationTarget.CapacityReservationResourceGroupArn != nil { + i.CapacityReservationResourceGroupARN = v.CapacityReservationSpecification.CapacityReservationTarget.CapacityReservationResourceGroupArn + } + if v.CapacityReservationSpecification != nil { + i.CapacityReservationPreference = SDKToCapacityReservationPreference(v.CapacityReservationSpecification.CapacityReservationPreference) + } if v.MetadataOptions != nil { i.InstanceMetadataOptions = &infrav1.InstanceMetadataOptions{ @@ -1038,6 +1057,14 @@ func (s *Service) LaunchTemplateNeedsUpdate(scope scope.LaunchTemplateScope, inc return true, services.LaunchTemplateNeedsUpdateReasonCapacityReservationID, nil } + if !cmp.Equal(incoming.CapacityReservationResourceGroupARN, existing.CapacityReservationResourceGroupARN) { + return true, services.LaunchTemplateNeedsUpdateReasonCapacityReservationResourceGroupARN, nil + } + + if !cmp.Equal(incoming.CapacityReservationPreference, existing.CapacityReservationPreference) { + return true, services.LaunchTemplateNeedsUpdateReasonCapacityReservationPreference, nil + } + if !cmp.Equal(incoming.PrivateDNSName, existing.PrivateDNSName) { return true, services.LaunchTemplateNeedsUpdateReasonPrivateDNSName, nil } @@ -1242,6 +1269,11 @@ func getLaunchTemplateInstanceMarketOptionsRequest(i *expinfrav1.AWSLaunchTempla return nil, errors.New("can't create spot capacity-blocks, remove spot market request") } + hasCapacityReservationTarget := infrav1.HasCapacityReservationTarget(i.CapacityReservationID, i.CapacityReservationResourceGroupARN) + if infrav1.HasConflictingCapacityReservationTargets(i.CapacityReservationID, i.CapacityReservationResourceGroupARN) { + return nil, errors.New("capacityReservationID and capacityReservationResourceGroupARN are mutually exclusive") + } + // Infer MarketType if not explicitly set and SpotMarketOptions specified if i.SpotMarketOptions != nil && i.MarketType == "" { i.MarketType = infrav1.MarketTypeSpot @@ -1255,14 +1287,18 @@ func getLaunchTemplateInstanceMarketOptionsRequest(i *expinfrav1.AWSLaunchTempla 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") + if !hasCapacityReservationTarget { + return nil, errors.Errorf("capacityReservationID or capacityReservationResourceGroupARN is required when CapacityBlock is enabled") } return &types.LaunchTemplateInstanceMarketOptionsRequest{ MarketType: types.MarketTypeCapacityBlock, }, nil case infrav1.MarketTypeSpot: + if hasCapacityReservationTarget { + return nil, errors.New("unable to generate marketOptions for spot instance, capacity reservation targets are incompatible with marketType spot and spotMarketOptions") + } + // Set required values for Spot instances spotOptions := &types.LaunchTemplateSpotMarketOptionsRequest{} diff --git a/pkg/cloud/services/ec2/launchtemplate_test.go b/pkg/cloud/services/ec2/launchtemplate_test.go index 3cce9b0e0a..f8ab58b0cf 100644 --- a/pkg/cloud/services/ec2/launchtemplate_test.go +++ b/pkg/cloud/services/ec2/launchtemplate_test.go @@ -502,6 +502,41 @@ func TestServiceSDKToLaunchTemplate(t *testing.T) { wantUserDataHash: testUserDataHash, wantDataSecretKey: nil, }, + { + name: "capacity reservation resource group ARN", + input: ec2types.LaunchTemplateVersion{ + LaunchTemplateId: aws.String("lt-12345"), + LaunchTemplateName: aws.String("foo"), + LaunchTemplateData: &ec2types.ResponseLaunchTemplateData{ + ImageId: aws.String("foo-image"), + IamInstanceProfile: &ec2types.LaunchTemplateIamInstanceProfileSpecification{ + Arn: aws.String("instance-profile/foo-profile"), + }, + KeyName: aws.String("foo-keyname"), + CapacityReservationSpecification: &ec2types.LaunchTemplateCapacityReservationSpecificationResponse{ + CapacityReservationTarget: &ec2types.CapacityReservationTargetResponse{ + CapacityReservationResourceGroupArn: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + CapacityReservationPreference: ec2types.CapacityReservationPreferenceCapacityReservationsOnly, + }, + UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(testUserData))), + }, + VersionNumber: aws.Int64(1), + }, + wantLT: &expinfrav1.AWSLaunchTemplate{ + Name: "foo", + AMI: infrav1.AMIReference{ + ID: aws.String("foo-image"), + }, + IamInstanceProfile: "foo-profile", + SSHKeyName: aws.String("foo-keyname"), + VersionNumber: aws.Int64(1), + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + CapacityReservationPreference: infrav1.CapacityReservationPreferenceOnly, + }, + wantUserDataHash: testUserDataHash, + wantDataSecretKey: nil, + }, { name: "tag of bootstrap secret", input: ec2types.LaunchTemplateVersion{ @@ -996,6 +1031,38 @@ func TestServiceLaunchTemplateNeedsUpdate(t *testing.T) { wantNeedsUpdateReason: services.LaunchTemplateNeedsUpdateReasonCapacityReservationID, wantErr: false, }, + { + name: "Should return true if capacity reservation resource group ARNs are different", + incoming: &expinfrav1.AWSLaunchTemplate{ + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/new-group"), + }, + existing: &expinfrav1.AWSLaunchTemplate{ + AdditionalSecurityGroups: []infrav1.AWSResourceReference{ + {ID: aws.String("sg-111")}, + {ID: aws.String("sg-222")}, + }, + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/old-group"), + }, + want: true, + wantNeedsUpdateReason: services.LaunchTemplateNeedsUpdateReasonCapacityReservationResourceGroupARN, + wantErr: false, + }, + { + name: "Should return true if capacity reservation preferences are different", + incoming: &expinfrav1.AWSLaunchTemplate{ + CapacityReservationPreference: infrav1.CapacityReservationPreferenceOnly, + }, + existing: &expinfrav1.AWSLaunchTemplate{ + AdditionalSecurityGroups: []infrav1.AWSResourceReference{ + {ID: aws.String("sg-111")}, + {ID: aws.String("sg-222")}, + }, + CapacityReservationPreference: infrav1.CapacityReservationPreferenceOpen, + }, + want: true, + wantNeedsUpdateReason: services.LaunchTemplateNeedsUpdateReasonCapacityReservationPreference, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1523,6 +1590,16 @@ func TestLaunchTemplateDataCreation(t *testing.T) { }) } +func TestGetLaunchTemplateCapacityReservationSpecification(t *testing.T) { + g := NewWithT(t) + + _, err := getLaunchTemplateCapacityReservationSpecification(&expinfrav1.AWSLaunchTemplate{ + CapacityReservationID: aws.String("cr-123"), + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }) + g.Expect(err).To(MatchError("capacityReservationID and capacityReservationResourceGroupARN are mutually exclusive")) +} + var LaunchTemplateVersionIgnoreUnexported = cmpopts.IgnoreUnexported( ec2types.CapacityReservationTarget{}, ec2types.LaunchTemplateCapacityReservationSpecificationRequest{}, @@ -1727,6 +1804,64 @@ func TestCreateLaunchTemplateVersion(t *testing.T) { }) }, }, + { + name: "Should successfully create launch template version with capacity reservation resource group ARN and preference", + awsResourceReference: []infrav1.AWSResourceReference{{ID: aws.String("1")}}, + mpScopeUpdater: func(mps *scope.MachinePoolScope) { + spec := mps.AWSMachinePool.Spec + spec.AWSLaunchTemplate.CapacityReservationResourceGroupARN = aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group") + spec.AWSLaunchTemplate.CapacityReservationPreference = infrav1.CapacityReservationPreferenceOnly + spec.AWSLaunchTemplate.SpotMarketOptions = nil + mps.AWSMachinePool.Spec = spec + }, + 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: &ec2types.RequestLaunchTemplateData{ + InstanceType: ec2types.InstanceTypeT3Large, + IamInstanceProfile: &ec2types.LaunchTemplateIamInstanceProfileSpecificationRequest{ + Name: aws.String("instance-profile"), + }, + KeyName: aws.String("default"), + UserData: ptr.To[string](base64.StdEncoding.EncodeToString(userData)), + SecurityGroupIds: []string{"nodeSG", "lbSG", "1"}, + ImageId: aws.String("imageID"), + CapacityReservationSpecification: &ec2types.LaunchTemplateCapacityReservationSpecificationRequest{ + CapacityReservationTarget: &ec2types.CapacityReservationTarget{ + CapacityReservationResourceGroupArn: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + CapacityReservationPreference: ec2types.CapacityReservationPreferenceCapacityReservationsOnly, + }, + TagSpecifications: []ec2types.LaunchTemplateTagSpecificationRequest{ + { + ResourceType: ec2types.ResourceTypeInstance, + Tags: defaultEC2AndDataTags("aws-mp-name", "cluster-name", userDataSecretKey, testBootstrapDataHash), + }, + { + ResourceType: ec2types.ResourceTypeVolume, + Tags: defaultEC2Tags("aws-mp-name", "cluster-name"), + }, + }, + }, + LaunchTemplateId: aws.String("launch-template-id"), + } + m.CreateLaunchTemplateVersion(context.TODO(), gomock.AssignableToTypeOf(expectedInput)).Return(&ec2.CreateLaunchTemplateVersionOutput{ + LaunchTemplateVersion: &ec2types.LaunchTemplateVersion{ + LaunchTemplateId: aws.String("launch-template-id"), + }, + }, nil).Do( + func(ctx context.Context, arg *ec2.CreateLaunchTemplateVersionInput, requestOptions ...ec2.Options) { + // formatting added to match tags slice during cmp.Equal() + formatTagsInput(arg) + if !cmp.Equal(expectedInput, arg, LaunchTemplateVersionIgnoreUnexported) { + t.Fatalf("mismatch in input expected: %+v, but got %+v, diff: %s", expectedInput, arg, cmp.Diff(expectedInput, arg, LaunchTemplateVersionIgnoreUnexported)) + } + }) + }, + }, { name: "Should return error if AWS failed during launch template version creation", awsResourceReference: []infrav1.AWSResourceReference{{ID: aws.String("1")}}, diff --git a/pkg/cloud/services/interfaces.go b/pkg/cloud/services/interfaces.go index 6deeaea1ae..881cbe18f8 100644 --- a/pkg/cloud/services/interfaces.go +++ b/pkg/cloud/services/interfaces.go @@ -58,6 +58,10 @@ const ( LaunchTemplateNeedsUpdateReasonSpotMarketOptions LaunchTemplateNeedsUpdateReason = "SpotMarketOptions" // LaunchTemplateNeedsUpdateReasonCapacityReservationID means a difference in the capacity reservation ID was found. LaunchTemplateNeedsUpdateReasonCapacityReservationID LaunchTemplateNeedsUpdateReason = "CapacityReservationID" + // LaunchTemplateNeedsUpdateReasonCapacityReservationResourceGroupARN means a difference in the capacity reservation resource group ARN was found. + LaunchTemplateNeedsUpdateReasonCapacityReservationResourceGroupARN LaunchTemplateNeedsUpdateReason = "CapacityReservationResourceGroupARN" + // LaunchTemplateNeedsUpdateReasonCapacityReservationPreference means a difference in the capacity reservation preference was found. + LaunchTemplateNeedsUpdateReasonCapacityReservationPreference LaunchTemplateNeedsUpdateReason = "CapacityReservationPreference" // LaunchTemplateNeedsUpdateReasonPrivateDNSName means a difference in the private DNS name was found. LaunchTemplateNeedsUpdateReasonPrivateDNSName LaunchTemplateNeedsUpdateReason = "PrivateDNSName" // LaunchTemplateNeedsUpdateReasonSSHKeyName means a difference in the SSH key name was found. diff --git a/webhooks/awsmachine_webhook.go b/webhooks/awsmachine_webhook.go index 3204842bd6..dd4a99dce3 100644 --- a/webhooks/awsmachine_webhook.go +++ b/webhooks/awsmachine_webhook.go @@ -395,8 +395,12 @@ func (w *AWSMachine) validateNetworkElasticIPPool(r *infrav1.AWSMachine) field.E func (w *AWSMachine) validateCapacityReservation(r *infrav1.AWSMachine) field.ErrorList { var allErrs field.ErrorList - if r.Spec.CapacityReservationID != nil && r.Spec.CapacityReservationPreference != infrav1.CapacityReservationPreferenceOnly && r.Spec.CapacityReservationPreference != "" { - allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "capacityReservationPreference"), "when capacityReservationId is specified, capacityReservationPreference may only be 'CapacityReservationsOnly' or empty")) + hasCapacityReservationTarget := infrav1.HasCapacityReservationTarget(r.Spec.CapacityReservationID, r.Spec.CapacityReservationResourceGroupARN) + if infrav1.HasConflictingCapacityReservationTargets(r.Spec.CapacityReservationID, r.Spec.CapacityReservationResourceGroupARN) { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "capacityReservationResourceGroupARN"), "capacityReservationId and capacityReservationResourceGroupARN are mutually exclusive")) + } + if hasCapacityReservationTarget && r.Spec.CapacityReservationPreference != infrav1.CapacityReservationPreferenceOnly && r.Spec.CapacityReservationPreference != "" { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "capacityReservationPreference"), "when a capacity reservation target is specified, capacityReservationPreference may only be 'CapacityReservationsOnly' or empty")) } if r.Spec.CapacityReservationPreference == infrav1.CapacityReservationPreferenceOnly && r.Spec.MarketType == infrav1.MarketTypeSpot { allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "capacityReservationPreference"), "when marketType is set to 'Spot', capacityReservationPreference cannot be set to 'CapacityReservationsOnly'")) @@ -404,6 +408,12 @@ func (w *AWSMachine) validateCapacityReservation(r *infrav1.AWSMachine) field.Er if r.Spec.CapacityReservationPreference == infrav1.CapacityReservationPreferenceOnly && r.Spec.SpotMarketOptions != nil { allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "capacityReservationPreference"), "when capacityReservationPreference is 'CapacityReservationsOnly', spotMarketOptions cannot be set (which implies marketType: 'Spot')")) } + if hasCapacityReservationTarget && r.Spec.MarketType == infrav1.MarketTypeSpot { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "marketType"), "cannot be set to 'Spot' when a capacity reservation target is specified")) + } + if hasCapacityReservationTarget && r.Spec.SpotMarketOptions != nil { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "spotMarketOptions"), "cannot be set when a capacity reservation target is specified")) + } return allErrs } @@ -415,8 +425,8 @@ func (w *AWSMachine) validateInstanceMarketType(r *infrav1.AWSMachine) field.Err if r.Spec.MarketType == infrav1.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 == infrav1.MarketTypeCapacityBlock && r.Spec.CapacityReservationID == nil { - allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "capacityReservationID"), "is required when CapacityBlock is provided")) + if r.Spec.MarketType == infrav1.MarketTypeCapacityBlock && !infrav1.HasCapacityReservationTarget(r.Spec.CapacityReservationID, r.Spec.CapacityReservationResourceGroupARN) { + allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "capacityReservationID"), "capacityReservationID or capacityReservationResourceGroupARN is required when CapacityBlock is provided")) } return allErrs } diff --git a/webhooks/awsmachine_webhook_test.go b/webhooks/awsmachine_webhook_test.go index 96b3b3b337..0db6dfd383 100644 --- a/webhooks/awsmachine_webhook_test.go +++ b/webhooks/awsmachine_webhook_test.go @@ -262,6 +262,28 @@ func TestAWSMachineCreate(t *testing.T) { }, wantErr: false, }, + { + name: "valid MarketType set to MarketTypeCapacityBlock and CapacityReservationResourceGroupARN are specified", + machine: &infrav1.AWSMachine{ + Spec: infrav1.AWSMachineSpec{ + MarketType: infrav1.MarketTypeCapacityBlock, + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + InstanceType: "test", + }, + }, + wantErr: false, + }, + { + name: "invalid case, CapacityReservationId and CapacityReservationResourceGroupARN are both set", + machine: &infrav1.AWSMachine{ + Spec: infrav1.AWSMachineSpec{ + InstanceType: "test", + CapacityReservationID: aws.String("cr-12345678901234567"), + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + }, + }, + wantErr: true, + }, { name: "invalid case, CapacityReservationId is set and CapacityReservationPreference is not `capacity-reservation-only`", machine: &infrav1.AWSMachine{ @@ -273,6 +295,17 @@ func TestAWSMachineCreate(t *testing.T) { }, wantErr: true, }, + { + name: "invalid case, CapacityReservationResourceGroupARN is set and CapacityReservationPreference is not `capacity-reservation-only`", + machine: &infrav1.AWSMachine{ + Spec: infrav1.AWSMachineSpec{ + InstanceType: "test", + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + CapacityReservationPreference: infrav1.CapacityReservationPreferenceNone, + }, + }, + wantErr: true, + }, { name: "valid CapacityReservationId is set and CapacityReservationPreference is not specified", machine: &infrav1.AWSMachine{ @@ -306,6 +339,17 @@ func TestAWSMachineCreate(t *testing.T) { }, wantErr: true, }, + { + name: "invalid CapacityReservationResourceGroupARN is set and MarketType is `Spot`", + machine: &infrav1.AWSMachine{ + Spec: infrav1.AWSMachineSpec{ + InstanceType: "test", + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + MarketType: infrav1.MarketTypeSpot, + }, + }, + wantErr: true, + }, { name: "invalid CapacityReservationPreference is `CapacityReservationsOnly` and SpotMarketOptions is non-nil", machine: &infrav1.AWSMachine{ @@ -318,6 +362,17 @@ func TestAWSMachineCreate(t *testing.T) { }, wantErr: true, }, + { + name: "invalid CapacityReservationResourceGroupARN is set and SpotMarketOptions is non-nil", + machine: &infrav1.AWSMachine{ + Spec: infrav1.AWSMachineSpec{ + InstanceType: "test", + CapacityReservationResourceGroupARN: aws.String("arn:aws:resource-groups:us-east-1:123456789012:group/capacity-reservation-group"), + SpotMarketOptions: &infrav1.SpotMarketOptions{}, + }, + }, + wantErr: true, + }, { name: "empty instance type not allowed", machine: &infrav1.AWSMachine{