diff --git a/api/v1alpha2/awsmachine_conversion.go b/api/v1alpha2/awsmachine_conversion.go index cde308f70d..3901182bb5 100644 --- a/api/v1alpha2/awsmachine_conversion.go +++ b/api/v1alpha2/awsmachine_conversion.go @@ -59,6 +59,10 @@ func restoreAWSMachineSpec(restored, dst *infrav1alpha3.AWSMachineSpec) { // manual conversion for UncompressedUserData dst.UncompressedUserData = restored.UncompressedUserData + + if restored.SpotMarketOptions != nil { + dst.SpotMarketOptions = restored.SpotMarketOptions.DeepCopy() + } } // ConvertFrom converts from the Hub version (v1alpha3) to this version. diff --git a/api/v1alpha2/zz_generated.conversion.go b/api/v1alpha2/zz_generated.conversion.go index bc35a89a09..d820022579 100644 --- a/api/v1alpha2/zz_generated.conversion.go +++ b/api/v1alpha2/zz_generated.conversion.go @@ -609,6 +609,7 @@ func autoConvert_v1alpha3_AWSMachineSpec_To_v1alpha2_AWSMachineSpec(in *v1alpha3 out.NetworkInterfaces = *(*[]string)(unsafe.Pointer(&in.NetworkInterfaces)) // WARNING: in.UncompressedUserData requires manual conversion: does not exist in peer-type // WARNING: in.CloudInit requires manual conversion: inconvertible types (sigs.k8s.io/cluster-api-provider-aws/api/v1alpha3.CloudInit vs *sigs.k8s.io/cluster-api-provider-aws/api/v1alpha2.CloudInit) + // WARNING: in.SpotMarketOptions requires manual conversion: does not exist in peer-type return nil } @@ -1013,6 +1014,7 @@ func autoConvert_v1alpha3_Instance_To_v1alpha2_Instance(in *v1alpha3.Instance, o out.NetworkInterfaces = *(*[]string)(unsafe.Pointer(&in.NetworkInterfaces)) out.Tags = *(*map[string]string)(unsafe.Pointer(&in.Tags)) // WARNING: in.AvailabilityZone requires manual conversion: does not exist in peer-type + // WARNING: in.SpotMarketOptions requires manual conversion: does not exist in peer-type return nil } diff --git a/api/v1alpha3/awsmachine_types.go b/api/v1alpha3/awsmachine_types.go index 56f61523ef..b67b636619 100644 --- a/api/v1alpha3/awsmachine_types.go +++ b/api/v1alpha3/awsmachine_types.go @@ -119,6 +119,10 @@ type AWSMachineSpec struct { // CloudInit is used. // +optional CloudInit CloudInit `json:"cloudInit,omitempty"` + + // SpotMarketOptions allows users to configure instances to be run using AWS Spot instances. + // +optional + SpotMarketOptions *SpotMarketOptions `json:"spotMarketOptions,omitempty"` } // CloudInit defines options related to the bootstrapping systems where diff --git a/api/v1alpha3/types.go b/api/v1alpha3/types.go index 874886c5bf..7d38f80c3b 100644 --- a/api/v1alpha3/types.go +++ b/api/v1alpha3/types.go @@ -626,6 +626,9 @@ type Instance struct { // Availability zone of instance AvailabilityZone string `json:"availabilityZone,omitempty"` + + // SpotMarketOptions option for configuring instances to be run using AWS Spot instances. + SpotMarketOptions *SpotMarketOptions `json:"spotMarketOptions,omitempty"` } // RootVolume encapsulates the configuration options for the root volume @@ -653,3 +656,13 @@ type RootVolume struct { // +optional EncryptionKey string `json:"encryptionKey,omitempty"` } + +// SpotMarketOptions defines the options available to a user when configuring +// Machines to run on Spot instances. +// Most users should provide an empty struct. +type SpotMarketOptions struct { + // MaxPrice defines the maximum price the user is willing to pay for Spot VM instances + // +optional + // +kubebuilder:validation:Type=number + MaxPrice *string `json:"maxPrice,omitempty"` +} diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index d2eafb4d4d..60c6d2bfa6 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -293,6 +293,11 @@ func (in *AWSMachineSpec) DeepCopyInto(out *AWSMachineSpec) { **out = **in } out.CloudInit = in.CloudInit + if in.SpotMarketOptions != nil { + in, out := &in.SpotMarketOptions, &out.SpotMarketOptions + *out = new(SpotMarketOptions) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSMachineSpec. @@ -831,6 +836,11 @@ func (in *Instance) DeepCopyInto(out *Instance) { (*out)[key] = val } } + if in.SpotMarketOptions != nil { + in, out := &in.SpotMarketOptions, &out.SpotMarketOptions + *out = new(SpotMarketOptions) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Instance. @@ -961,6 +971,26 @@ func (in *SecurityGroup) DeepCopy() *SecurityGroup { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SpotMarketOptions) DeepCopyInto(out *SpotMarketOptions) { + *out = *in + if in.MaxPrice != nil { + in, out := &in.MaxPrice, &out.MaxPrice + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SpotMarketOptions. +func (in *SpotMarketOptions) DeepCopy() *SpotMarketOptions { + if in == nil { + return nil + } + out := new(SpotMarketOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SubnetSpec) DeepCopyInto(out *SubnetSpec) { *out = *in 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 1782410e9c..79a6173a86 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsclusters.yaml @@ -631,6 +631,13 @@ spec: items: type: string type: array + spotMarketOptions: + description: SpotMarketOptions option for configuring instances to be run using AWS Spot instances. + properties: + maxPrice: + description: MaxPrice defines the maximum price the user is willing to pay for Spot VM instances + type: number + type: object sshKeyName: description: The name of the SSH key pair. 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 deefc2fb6e..755a4fe169 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachines.yaml @@ -378,6 +378,13 @@ spec: required: - size type: object + spotMarketOptions: + description: SpotMarketOptions allows users to configure instances to be run using AWS Spot instances. + properties: + maxPrice: + description: MaxPrice defines the maximum price the user is willing to pay for Spot VM instances + type: number + type: object sshKeyName: description: SSHKeyName is the name of the ssh key to attach to the instance. Valid values are empty string (do not use SSH keys), a valid SSH key name, or omitted (use the default SSH key name) type: string 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 b1b8f88f3d..566b2ec807 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_awsmachinetemplates.yaml @@ -341,6 +341,13 @@ spec: required: - size type: object + spotMarketOptions: + description: SpotMarketOptions allows users to configure instances to be run using AWS Spot instances. + properties: + maxPrice: + description: MaxPrice defines the maximum price the user is willing to pay for Spot VM instances + type: number + type: object sshKeyName: description: SSHKeyName is the name of the ssh key to attach to the instance. Valid values are empty string (do not use SSH keys), a valid SSH key name, or omitted (use the default SSH key name) type: string diff --git a/pkg/cloud/services/ec2/instances.go b/pkg/cloud/services/ec2/instances.go index fbb9dea441..d1414b9870 100644 --- a/pkg/cloud/services/ec2/instances.go +++ b/pkg/cloud/services/ec2/instances.go @@ -197,6 +197,8 @@ func (s *Service) CreateInstance(scope *scope.MachineScope, userData []byte) (*i } } + input.SpotMarketOptions = scope.AWSMachine.Spec.SpotMarketOptions + s.scope.V(2).Info("Running instance", "machine-role", scope.Role()) out, err := s.runInstance(scope.Role(), input) if err != nil { @@ -449,6 +451,8 @@ func (s *Service) runInstance(role string, i *infrav1.Instance) (*infrav1.Instan input.TagSpecifications = append(input.TagSpecifications, spec) } + input.InstanceMarketOptions = getInstanceMarketOptionsRequest(i.SpotMarketOptions) + out, err := s.EC2Client.RunInstances(input) if err != nil { return nil, errors.Wrap(err, "failed to run instance") @@ -790,3 +794,33 @@ func containsGroup(list []string, strToSearch string) bool { } return false } + +func getInstanceMarketOptionsRequest(spotMarketOptions *infrav1.SpotMarketOptions) *ec2.InstanceMarketOptionsRequest { + if spotMarketOptions == nil { + // Instance is not a Spot instance + return nil + } + + // 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) + + maxPrice := spotMarketOptions.MaxPrice + if maxPrice != nil && *maxPrice != "" { + spotOptions.SetMaxPrice(*maxPrice) + } + + instanceMarketOptionsRequest := &ec2.InstanceMarketOptionsRequest{} + instanceMarketOptionsRequest.SetMarketType(ec2.MarketTypeSpot) + instanceMarketOptionsRequest.SetSpotOptions(spotOptions) + + return instanceMarketOptionsRequest +} diff --git a/pkg/cloud/services/ec2/instances_test.go b/pkg/cloud/services/ec2/instances_test.go index bb3de5bd83..0bde90fbdb 100644 --- a/pkg/cloud/services/ec2/instances_test.go +++ b/pkg/cloud/services/ec2/instances_test.go @@ -17,6 +17,7 @@ limitations under the License. package ec2 import ( + "reflect" "testing" "github.com/aws/aws-sdk-go/aws" @@ -1078,6 +1079,67 @@ func TestCreateInstance(t *testing.T) { } } +func TestGetInstanceMarketOptionsRequest(t *testing.T) { + testCases := []struct { + name string + spotMarketOptions *infrav1.SpotMarketOptions + expectedRequest *ec2.InstanceMarketOptionsRequest + }{ + { + name: "with no Spot options specified", + spotMarketOptions: nil, + expectedRequest: nil, + }, + { + name: "with an empty Spot options specified", + spotMarketOptions: &infrav1.SpotMarketOptions{}, + expectedRequest: &ec2.InstanceMarketOptionsRequest{ + MarketType: aws.String(ec2.MarketTypeSpot), + SpotOptions: &ec2.SpotMarketOptions{ + InstanceInterruptionBehavior: aws.String(ec2.InstanceInterruptionBehaviorTerminate), + SpotInstanceType: aws.String(ec2.SpotInstanceTypeOneTime), + }, + }, + }, + { + name: "with an empty MaxPrice specified", + spotMarketOptions: &infrav1.SpotMarketOptions{ + MaxPrice: aws.String(""), + }, + expectedRequest: &ec2.InstanceMarketOptionsRequest{ + MarketType: aws.String(ec2.MarketTypeSpot), + SpotOptions: &ec2.SpotMarketOptions{ + InstanceInterruptionBehavior: aws.String(ec2.InstanceInterruptionBehaviorTerminate), + SpotInstanceType: aws.String(ec2.SpotInstanceTypeOneTime), + }, + }, + }, + { + name: "with a valid MaxPrice specified", + spotMarketOptions: &infrav1.SpotMarketOptions{ + MaxPrice: aws.String("0.01"), + }, + expectedRequest: &ec2.InstanceMarketOptionsRequest{ + MarketType: aws.String(ec2.MarketTypeSpot), + SpotOptions: &ec2.SpotMarketOptions{ + InstanceInterruptionBehavior: aws.String(ec2.InstanceInterruptionBehaviorTerminate), + SpotInstanceType: aws.String(ec2.SpotInstanceTypeOneTime), + MaxPrice: aws.String("0.01"), + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + request := getInstanceMarketOptionsRequest(tc.spotMarketOptions) + if !reflect.DeepEqual(request, tc.expectedRequest) { + t.Errorf("Case: %s. Got: %v, expected: %v", tc.name, request, tc.expectedRequest) + } + }) + } +} + func setupScheme() (*runtime.Scheme, error) { scheme := runtime.NewScheme() if err := clusterv1.AddToScheme(scheme); err != nil {