From 20ee19612a5a2878bceea2e41fa86af440ef1e3f Mon Sep 17 00:00:00 2001 From: Gavin Burris <66969321+GavinBurris42@users.noreply.github.com> Date: Fri, 29 Jul 2022 13:49:46 -0500 Subject: [PATCH] Add support for launching Spot instances (#85) * Changed year default format for tag value message * Added new SpotOrOnDemand question to simple-ec2 * Refactored Capacity Type question and included capacity type in launch table * Added and modified tests to test the new CapacityType question * Capacity Question formatting * Added struct for capacity type terms * Correcting PR issues due Capacity question * PR changes to restructure methods * Created functions for creating and deleting Launch Templates. Added these methods into flow on creating a spot instance * Created mocks and tests for testing creating and deleting Launch Templates * Addressed issues to Creating and Deleting launch templates from PR. Fixed issues with Launch Template tests as well * Added CreateFleet method. Restructured LaunchTemplate testing methods to use an existing mock method. * Refactored tests for creating and deleting launch templates * Formatted the CLI output when a Spot instance is created * Created tests for using CreateFleet to create a Spot instance * Added security groups and subnet to launch template allowing spot instances to be connectable. Also fixed bug so launch templates can create spot instances. * Configured spot instances to include VPC, subnet, security groups, EBS configuration, tags, auto-termination, and other configurations * Revised duplicate code with data configuration struct * Refactored Request Instance configuration struct * Added real time pricing for spot and onDemand instances for Capacity type question * Update pkg/ec2helper/ec2helper.go Co-authored-by: Steve Nay <265958+snay2@users.noreply.github.com> * Update pkg/question/question.go Co-authored-by: Steve Nay <265958+snay2@users.noreply.github.com> Co-authored-by: Burris Co-authored-by: Steve Nay <265958+snay2@users.noreply.github.com> --- cmd/launch.go | 30 ++- go.mod | 2 +- pkg/cli/cli.go | 1 + pkg/config/config.go | 14 ++ pkg/config/config_test.go | 5 +- pkg/ec2helper/ec2helper.go | 303 +++++++++++++++++++++++------- pkg/ec2helper/ec2helper_test.go | 75 ++++++++ pkg/ec2helper/types.go | 3 + pkg/question/question.go | 45 +++++ pkg/question/question_test.go | 11 ++ test/testhelper/ec2helper_mock.go | 32 ++++ 11 files changed, 444 insertions(+), 77 deletions(-) diff --git a/cmd/launch.go b/cmd/launch.go index 995ef81..5f21a01 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -159,6 +159,9 @@ func launchInteractive(h *ec2helper.EC2Helper) { return } + // Ask for and set the capacity type + simpleConfig.CapacityType = question.AskCapacityType(simpleConfig.InstanceType) + // Ask for confirmation or modification confirmation = question.AskConfirmationWithInput(simpleConfig, detailedConfig, true) @@ -209,8 +212,9 @@ func launchInteractive(h *ec2helper.EC2Helper) { } } - // Launch the instance. - _, err = h.LaunchInstance(simpleConfig, detailedConfig, confirmation == cli.ResponseYes) + // Launch On-Demand or Spot instance based on capacity type + err = LaunchCapacityInstance(h, simpleConfig, detailedConfig, confirmation) + if cli.ShowError(err, "Launching instance failed") { return } @@ -260,14 +264,26 @@ func launchNonInteractive(h *ec2helper.EC2Helper) { confirmation := question.AskConfirmationWithInput(simpleConfig, detailedConfig, false) - // Launch the instance. - _, err = h.LaunchInstance(simpleConfig, detailedConfig, confirmation == cli.ResponseYes) + LaunchCapacityInstance(h, simpleConfig, detailedConfig, confirmation) + if cli.ShowError(err, "Launching instance failed") { return } ReadSaveConfig(simpleConfig) } +// Launch On-Demand or Spot instance based on capacity type +func LaunchCapacityInstance(h *ec2helper.EC2Helper, simpleConfig *config.SimpleInfo, detailedConfig *config.DetailedInfo, + confirmation string) error { + var err error + if simpleConfig.CapacityType == question.DefaultCapacityTypeText.OnDemand { + _, err = h.LaunchInstance(simpleConfig, detailedConfig, confirmation == cli.ResponseYes) + } else { + err = h.LaunchSpotInstance(simpleConfig, detailedConfig, confirmation == cli.ResponseYes) + } + return err +} + // Validate flags using some simple rules. Return true if the flags are validated, false otherwise func ValidateLaunchFlags(flags *config.SimpleInfo) bool { if flags.LaunchTemplateVersion != "" && flags.LaunchTemplateId == "" { @@ -321,13 +337,17 @@ func UseLaunchTemplateWithConfig(h *ec2helper.EC2Helper, simpleConfig *config.Si // Launch an instance with a launch template func LaunchWithLaunchTemplate(h *ec2helper.EC2Helper, simpleConfig *config.SimpleInfo) { + versions, err := h.GetLaunchTemplateVersions(simpleConfig.LaunchTemplateId, + &simpleConfig.LaunchTemplateVersion) + templateData := versions[0].LaunchTemplateData + simpleConfig.CapacityType = question.AskCapacityType(*templateData.InstanceType) confirmation, err := question.AskConfirmationWithTemplate(h, simpleConfig) if cli.ShowError(err, "Asking confirmation with launch template failed") { return } // Launch the instance. - _, err = h.LaunchInstance(simpleConfig, nil, *confirmation == cli.ResponseYes) + err = LaunchCapacityInstance(h, simpleConfig, nil, *confirmation) if cli.ShowError(err, "Launching instance failed") { return } diff --git a/go.mod b/go.mod index 03bd4f2..b60812d 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/fatih/color v1.13.0 // indirect - github.com/google/uuid v1.3.0 + github.com/google/uuid v1.3.0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index bb6954b..c028b26 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -40,6 +40,7 @@ const ( ResourceIamInstanceProfile = "IAM Instance Profile" ResourceBootScriptFilePath = "Boot Script Filepath" ResourceUserTags = "Tag Specification(key|value)" + ResourceCapacityType = "Capacity Type" ) // Show errors if there are any. Return true when there are errors, and false when there is none diff --git a/pkg/config/config.go b/pkg/config/config.go index d920154..6a19a8b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -47,6 +47,7 @@ type SimpleInfo struct { IamInstanceProfile string BootScriptFilePath string UserTags map[string]string + CapacityType string } /* @@ -62,6 +63,19 @@ type DetailedInfo struct { TagSpecs []*ec2.TagSpecification } +type RequestInstanceInfo struct { + ImageId *string + InstanceType *string + SubnetId *string + SecurityGroupIds []*string + IamInstanceProfile *ec2.IamInstanceProfileSpecification + LaunchTemplate *ec2.LaunchTemplateSpecification + BlockDeviceMappings []*ec2.BlockDeviceMapping + LaunchTemplateBlockMappings []*ec2.LaunchTemplateBlockDeviceMappingRequest + InstanceInitiatedShutdownBehavior *string + UserData *string +} + func NewSimpleInfo() *SimpleInfo { var s SimpleInfo s.UserTags = make(map[string]string) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 572ae93..57ef8e9 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -53,11 +53,12 @@ const testLaunchTemplateVersion = "1" const testNewVPC = true const testIamProfile = "iam-profile" const testBootScriptFilePath = "some/path/to/bootscript" +const testCapacityType = "On-Spot-Demand" var testTags = map[string]string{"testedBy": "BRYAN", "brokenBy": "CBASKIN"} var testSecurityGroup = []string{"sg-12345", "sg-67890"} -const expectedJson = `{"Region":"us-somewhere","ImageId":"ami-12345","InstanceType":"t2.micro","SubnetId":"s-12345","LaunchTemplateId":"lt-12345","LaunchTemplateVersion":"1","SecurityGroupIds":["sg-12345","sg-67890"],"NewVPC":true,"AutoTerminationTimerMinutes":0,"KeepEbsVolumeAfterTermination":false,"IamInstanceProfile":"iam-profile","BootScriptFilePath":"some/path/to/bootscript","UserTags":{"brokenBy":"CBASKIN","testedBy":"BRYAN"}}` +const expectedJson = `{"Region":"us-somewhere","ImageId":"ami-12345","InstanceType":"t2.micro","SubnetId":"s-12345","LaunchTemplateId":"lt-12345","LaunchTemplateVersion":"1","SecurityGroupIds":["sg-12345","sg-67890"],"NewVPC":true,"AutoTerminationTimerMinutes":0,"KeepEbsVolumeAfterTermination":false,"IamInstanceProfile":"iam-profile","BootScriptFilePath":"some/path/to/bootscript","UserTags":{"brokenBy":"CBASKIN","testedBy":"BRYAN"},"CapacityType":"On-Spot-Demand"}` func TestSaveConfig(t *testing.T) { testConfig := &config.SimpleInfo{ @@ -72,6 +73,7 @@ func TestSaveConfig(t *testing.T) { IamInstanceProfile: testIamProfile, BootScriptFilePath: testBootScriptFilePath, UserTags: testTags, + CapacityType: testCapacityType, } err := config.SaveConfig(testConfig, aws.String(testConfigFileName)) @@ -126,6 +128,7 @@ func TestReadConfig(t *testing.T) { IamInstanceProfile: testIamProfile, BootScriptFilePath: testBootScriptFilePath, UserTags: testTags, + CapacityType: testCapacityType, } th.Equals(t, expectedConfig, actualConfig) } diff --git a/pkg/ec2helper/ec2helper.go b/pkg/ec2helper/ec2helper.go index 4f8d733..c4f0a33 100644 --- a/pkg/ec2helper/ec2helper.go +++ b/pkg/ec2helper/ec2helper.go @@ -31,6 +31,7 @@ import ( "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" "github.com/aws/amazon-ec2-instance-selector/v2/pkg/selector" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" "github.com/google/uuid" @@ -992,77 +993,20 @@ func (h *EC2Helper) ParseConfig(simpleConfig *config.SimpleInfo) (*config.Detail // Get a RunInstanceInput given a structured config func getRunInstanceInput(simpleConfig *config.SimpleInfo, detailedConfig *config.DetailedInfo) *ec2.RunInstancesInput { - input := &ec2.RunInstancesInput{ - MaxCount: aws.Int64(1), - MinCount: aws.Int64(1), + dataConfig := createRequestInstanceConfig(simpleConfig, detailedConfig) + return &ec2.RunInstancesInput{ + MaxCount: aws.Int64(1), + MinCount: aws.Int64(1), + LaunchTemplate: dataConfig.LaunchTemplate, + ImageId: dataConfig.ImageId, + InstanceType: dataConfig.InstanceType, + SubnetId: dataConfig.SubnetId, + SecurityGroupIds: dataConfig.SecurityGroupIds, + IamInstanceProfile: dataConfig.IamInstanceProfile, + BlockDeviceMappings: dataConfig.BlockDeviceMappings, + InstanceInitiatedShutdownBehavior: dataConfig.InstanceInitiatedShutdownBehavior, + UserData: dataConfig.UserData, } - - // Add launch template if present - if simpleConfig.LaunchTemplateId != "" { - input.LaunchTemplate = &ec2.LaunchTemplateSpecification{ - LaunchTemplateId: aws.String(simpleConfig.LaunchTemplateId), - Version: aws.String(simpleConfig.LaunchTemplateVersion), - } - } - - //Override settings if applicable - if simpleConfig.ImageId != "" { - input.ImageId = aws.String(simpleConfig.ImageId) - } - if simpleConfig.InstanceType != "" { - input.InstanceType = aws.String(simpleConfig.InstanceType) - } - if simpleConfig.SubnetId != "" { - input.SubnetId = aws.String(simpleConfig.SubnetId) - } - if simpleConfig.SecurityGroupIds != nil && len(simpleConfig.SecurityGroupIds) > 0 { - input.SecurityGroupIds = aws.StringSlice(simpleConfig.SecurityGroupIds) - } - if simpleConfig.IamInstanceProfile != "" { - input.IamInstanceProfile = &ec2.IamInstanceProfileSpecification{ - Name: aws.String(simpleConfig.IamInstanceProfile), - } - } - - setAutoTermination := false - if detailedConfig != nil { - // Set all EBS volumes not to be deleted, if specified - if HasEbsVolume(detailedConfig.Image) && simpleConfig.KeepEbsVolumeAfterTermination { - input.BlockDeviceMappings = detailedConfig.Image.BlockDeviceMappings - for _, block := range input.BlockDeviceMappings { - if block.Ebs != nil { - block.Ebs.DeleteOnTermination = aws.Bool(false) - } - } - } - setAutoTermination = IsLinux(*detailedConfig.Image.PlatformDetails) && simpleConfig.AutoTerminationTimerMinutes > 0 - } - - if setAutoTermination { - input.InstanceInitiatedShutdownBehavior = aws.String("terminate") - autoTermCmd := fmt.Sprintf("#!/bin/bash\necho \"sudo poweroff\" | at now + %d minutes\n", - simpleConfig.AutoTerminationTimerMinutes) - if simpleConfig.BootScriptFilePath == "" { - input.UserData = aws.String(base64.StdEncoding.EncodeToString([]byte(autoTermCmd))) - } else { - bootScriptRaw, _ := ioutil.ReadFile(simpleConfig.BootScriptFilePath) - bootScriptLines := strings.Split(string(bootScriptRaw), "\n") - //if #!/bin/bash is first, then replace first line otherwise, prepend termination - if len(bootScriptLines) >= 1 && bootScriptLines[0] == "#!/bin/bash" { - bootScriptLines[0] = autoTermCmd - } else { - bootScriptLines = append([]string{autoTermCmd}, bootScriptLines...) - } - bootScriptRaw = []byte(strings.Join(bootScriptLines, "\n")) - input.UserData = aws.String(base64.StdEncoding.EncodeToString(bootScriptRaw)) - } - } else { - if simpleConfig.BootScriptFilePath != "" { - bootScriptRaw, _ := ioutil.ReadFile(simpleConfig.BootScriptFilePath) - input.UserData = aws.String(base64.StdEncoding.EncodeToString(bootScriptRaw)) - } - } - return input } // Get the default string config @@ -1121,6 +1065,8 @@ func (h *EC2Helper) GetDefaultSimpleConfig() (*config.SimpleInfo, error) { simpleConfig.SecurityGroupIds = []string{*defaultSg.GroupId} } + simpleConfig.CapacityType = "On-Demand" + return simpleConfig, nil } @@ -1164,6 +1110,33 @@ func (h *EC2Helper) LaunchInstance(simpleConfig *config.SimpleInfo, detailedConf } } +func (h *EC2Helper) LaunchSpotInstance(simpleConfig *config.SimpleInfo, detailedConfig *config.DetailedInfo, confirmation bool) error { + var err error + if confirmation { + fmt.Println("Options confirmed! Launching spot instance...") + if simpleConfig.LaunchTemplateId != "" { + _, err = h.LaunchFleet(aws.String(simpleConfig.LaunchTemplateId)) + } else { + template, err := h.CreateLaunchTemplate(simpleConfig, detailedConfig) + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + fmt.Println(aerr.Error()) + } else { + fmt.Println(err.Error()) + } + return err + } + _, err = h.LaunchFleet(template.LaunchTemplateId) + err = h.DeleteLaunchTemplate(template.LaunchTemplateId) + } + } else { + // Abort + return errors.New("Options not confirmed") + } + + return err +} + // Create a new stack and update simpleConfig for config saving func (h *EC2Helper) createNetworkConfiguration(simpleConfig *config.SimpleInfo, input *ec2.RunInstancesInput) error { @@ -1333,3 +1306,193 @@ func HasEbsVolume(image *ec2.Image) bool { return false } + +func (h *EC2Helper) CreateLaunchTemplate(simpleConfig *config.SimpleInfo, detailedConfig *config.DetailedInfo) (*ec2.LaunchTemplate, error) { + launchIdentifier := uuid.New() + + fmt.Println("Creating Launch Template...") + + dataConfig := createRequestInstanceConfig(simpleConfig, detailedConfig) + input := &ec2.CreateLaunchTemplateInput{ + LaunchTemplateData: &ec2.RequestLaunchTemplateData{ + NetworkInterfaces: []*ec2.LaunchTemplateInstanceNetworkInterfaceSpecificationRequest{ + { + AssociatePublicIpAddress: aws.Bool(true), + DeviceIndex: aws.Int64(0), + Groups: dataConfig.SecurityGroupIds, + SubnetId: dataConfig.SubnetId, + }, + }, + IamInstanceProfile: (*ec2.LaunchTemplateIamInstanceProfileSpecificationRequest)(dataConfig.IamInstanceProfile), + ImageId: dataConfig.ImageId, + InstanceType: dataConfig.InstanceType, + BlockDeviceMappings: dataConfig.LaunchTemplateBlockMappings, + InstanceInitiatedShutdownBehavior: dataConfig.InstanceInitiatedShutdownBehavior, + UserData: dataConfig.UserData, + }, + LaunchTemplateName: aws.String(fmt.Sprintf("SimpleEC2LaunchTemplate-%s", launchIdentifier)), + VersionDescription: aws.String(fmt.Sprintf("Launch Template %s", launchIdentifier)), + } + + result, err := h.Svc.CreateLaunchTemplate(input) + return result.LaunchTemplate, err +} + +func createRequestInstanceConfig(simpleConfig *config.SimpleInfo, detailedConfig *config.DetailedInfo) config.RequestInstanceInfo { + requestInstanceConfig := config.RequestInstanceInfo{} + + if simpleConfig.LaunchTemplateId != "" { + requestInstanceConfig.LaunchTemplate = &ec2.LaunchTemplateSpecification{ + LaunchTemplateId: aws.String(simpleConfig.LaunchTemplateId), + Version: aws.String(simpleConfig.LaunchTemplateVersion), + } + } + + if simpleConfig.ImageId != "" { + requestInstanceConfig.ImageId = aws.String(simpleConfig.ImageId) + } + if simpleConfig.InstanceType != "" { + requestInstanceConfig.InstanceType = aws.String(simpleConfig.InstanceType) + } + if simpleConfig.SubnetId != "" { + requestInstanceConfig.SubnetId = aws.String(simpleConfig.SubnetId) + } + if simpleConfig.SecurityGroupIds != nil && len(simpleConfig.SecurityGroupIds) > 0 { + requestInstanceConfig.SecurityGroupIds = aws.StringSlice(simpleConfig.SecurityGroupIds) + } + if simpleConfig.IamInstanceProfile != "" { + requestInstanceConfig.IamInstanceProfile = &ec2.IamInstanceProfileSpecification{ + Name: aws.String(simpleConfig.IamInstanceProfile), + } + } + + setAutoTermination := false + if detailedConfig != nil { + // Set all EBS volumes not to be deleted, if specified + if HasEbsVolume(detailedConfig.Image) && simpleConfig.KeepEbsVolumeAfterTermination { + requestInstanceConfig.BlockDeviceMappings = detailedConfig.Image.BlockDeviceMappings + for _, block := range requestInstanceConfig.BlockDeviceMappings { + if block.Ebs != nil { + block.Ebs.DeleteOnTermination = aws.Bool(false) + } + } + blockDevices := []*ec2.LaunchTemplateBlockDeviceMappingRequest{} + for index, block := range detailedConfig.Image.BlockDeviceMappings { + blockDevices = append(blockDevices, &ec2.LaunchTemplateBlockDeviceMappingRequest{ + DeviceName: block.DeviceName, + NoDevice: block.NoDevice, + VirtualName: block.VirtualName, + }) + if block.Ebs != nil { + blockDeviceEbs := &ec2.LaunchTemplateEbsBlockDeviceRequest{ + DeleteOnTermination: aws.Bool(false), + Encrypted: block.Ebs.Encrypted, + Iops: block.Ebs.Iops, + KmsKeyId: block.Ebs.KmsKeyId, + SnapshotId: block.Ebs.SnapshotId, + Throughput: block.Ebs.Throughput, + VolumeSize: block.Ebs.VolumeSize, + VolumeType: block.Ebs.VolumeType, + } + blockDevices[index].SetEbs(blockDeviceEbs) + } + } + requestInstanceConfig.LaunchTemplateBlockMappings = blockDevices + } + setAutoTermination = IsLinux(*detailedConfig.Image.PlatformDetails) && simpleConfig.AutoTerminationTimerMinutes > 0 + } + + if setAutoTermination { + requestInstanceConfig.InstanceInitiatedShutdownBehavior = aws.String("terminate") + autoTermCmd := fmt.Sprintf("#!/bin/bash\necho \"sudo poweroff\" | at now + %d minutes\n", + simpleConfig.AutoTerminationTimerMinutes) + if simpleConfig.BootScriptFilePath == "" { + requestInstanceConfig.UserData = aws.String(base64.StdEncoding.EncodeToString([]byte(autoTermCmd))) + } else { + bootScriptRaw, _ := ioutil.ReadFile(simpleConfig.BootScriptFilePath) + bootScriptLines := strings.Split(string(bootScriptRaw), "\n") + //if #!/bin/bash is first, then replace first line otherwise, prepend termination + if len(bootScriptLines) >= 1 && bootScriptLines[0] == "#!/bin/bash" { + bootScriptLines[0] = autoTermCmd + } else { + bootScriptLines = append([]string{autoTermCmd}, bootScriptLines...) + } + bootScriptRaw = []byte(strings.Join(bootScriptLines, "\n")) + requestInstanceConfig.UserData = aws.String(base64.StdEncoding.EncodeToString(bootScriptRaw)) + } + } else { + if simpleConfig.BootScriptFilePath != "" { + bootScriptRaw, _ := ioutil.ReadFile(simpleConfig.BootScriptFilePath) + requestInstanceConfig.UserData = aws.String(base64.StdEncoding.EncodeToString(bootScriptRaw)) + } + } + + return requestInstanceConfig +} + +func (h *EC2Helper) DeleteLaunchTemplate(templateId *string) error { + fmt.Println("Deleting Launch Template...") + input := &ec2.DeleteLaunchTemplateInput{ + LaunchTemplateId: templateId, + } + + _, err := h.Svc.DeleteLaunchTemplate(input) + return err +} + +func (h *EC2Helper) LaunchFleet(templateId *string) (*ec2.CreateFleetOutput, error) { + fleetTemplateSpecs := &ec2.FleetLaunchTemplateSpecificationRequest{ + LaunchTemplateId: templateId, + Version: aws.String("$Latest"), + } + + fleetTemplateConfig := []*ec2.FleetLaunchTemplateConfigRequest{ + { + LaunchTemplateSpecification: fleetTemplateSpecs, + }, + } + + spotRequest := &ec2.SpotOptionsRequest{ + AllocationStrategy: aws.String("capacity-optimized"), + } + + targetCapacity := &ec2.TargetCapacitySpecificationRequest{ + DefaultTargetCapacityType: aws.String("spot"), + OnDemandTargetCapacity: aws.Int64(0), + SpotTargetCapacity: aws.Int64(1), + TotalTargetCapacity: aws.Int64(1), + } + + input := &ec2.CreateFleetInput{ + LaunchTemplateConfigs: fleetTemplateConfig, + SpotOptions: spotRequest, + TargetCapacitySpecification: targetCapacity, + Type: aws.String("instant"), + } + + result, err := h.Svc.CreateFleet(input) + + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + fmt.Println(aerr.Error()) + } else { + fmt.Println(err.Error()) + } + return nil, err + } else { + if len(result.Errors) != 0 { + err = errors.New(*result.Errors[0].ErrorMessage) + cli.ShowError(err, "Creating spot instance failed") + return nil, err + } + } + + fmt.Println("Launch Spot Instance Success!") + for _, instance := range result.Instances { + for _, id := range instance.InstanceIds { + fmt.Printf("Spot Instance ID: %s\n", *id) + } + } + + return result, err +} diff --git a/pkg/ec2helper/ec2helper_test.go b/pkg/ec2helper/ec2helper_test.go index 195c46a..74d0f48 100644 --- a/pkg/ec2helper/ec2helper_test.go +++ b/pkg/ec2helper/ec2helper_test.go @@ -15,6 +15,7 @@ package ec2helper_test import ( "errors" + "fmt" "io/ioutil" "os" "testing" @@ -154,6 +155,8 @@ func TestGetAvailableAvailabilityZones_NoResult(t *testing.T) { Launch Template Tests */ +var testLaunchId = "lt-12345" + func TestGetLaunchTemplatesInRegion_Success(t *testing.T) { expectedTemplates := []*ec2.LaunchTemplate{ { @@ -227,6 +230,69 @@ func TestGetLaunchTemplateById_DescribeLaunchTemplatesPagesError(t *testing.T) { th.Nok(t, err) } +func TestCreateLaunchTemplate(t *testing.T) { + simpleConfig := &config.SimpleInfo{ + ImageId: "ami-12345", + InstanceType: "t2.micro", + SubnetId: "subnet-12345", + } + detailedConfig := &config.DetailedInfo{ + Image: &ec2.Image{ + ImageId: aws.String("ami-12345"), + PlatformDetails: aws.String("test deatils"), + }, + } + fmt.Println(*detailedConfig) + fmt.Println(*detailedConfig.Image) + testEC2.Svc = &th.MockedEC2Svc{} + testEC2.CreateLaunchTemplate(simpleConfig, detailedConfig) + + templates := []*ec2.LaunchTemplate{} + + err := testEC2.Svc.DescribeLaunchTemplatesPages(&ec2.DescribeLaunchTemplatesInput{}, func(page *ec2.DescribeLaunchTemplatesOutput, lastPage bool) bool { + templates = append(templates, page.LaunchTemplates...) + return !lastPage + }) + th.Equals(t, nil, err) + + isCreated := false + + for _, template := range templates { + if *template.LaunchTemplateId == testLaunchId { + isCreated = true + break + } + } + th.Equals(t, true, isCreated) +} + +func TestDeleteLaunchTemplate(t *testing.T) { + testEC2.Svc = &th.MockedEC2Svc{ + LaunchTemplates: []*ec2.LaunchTemplate{ + {LaunchTemplateId: &testLaunchId}, + }, + } + testEC2.DeleteLaunchTemplate(&testLaunchId) + + templates := []*ec2.LaunchTemplate{} + + err := testEC2.Svc.DescribeLaunchTemplatesPages(&ec2.DescribeLaunchTemplatesInput{}, func(page *ec2.DescribeLaunchTemplatesOutput, lastPage bool) bool { + templates = append(templates, page.LaunchTemplates...) + return !lastPage + }) + th.Equals(t, nil, err) + + isDeleted := true + + for _, template := range templates { + if *template.LaunchTemplateId == testLaunchId { + isDeleted = false + break + } + } + th.Equals(t, true, isDeleted) +} + /* Launch Template Version Tests */ @@ -1205,6 +1271,15 @@ func TestLaunchInstance_DescribeImagesError(t *testing.T) { th.Nok(t, err) } +func TestLaunchFleet(t *testing.T) { + const testInstanceId = ("i-12345") + testEC2.Svc = &th.MockedEC2Svc{} + fleetOutput, _ := testEC2.LaunchFleet(&testLaunchId) + + th.Equals(t, 1, len(fleetOutput.Instances)) + th.Equals(t, testInstanceId, *fleetOutput.Instances[0].InstanceIds[0]) +} + /* Terminate Tests */ diff --git a/pkg/ec2helper/types.go b/pkg/ec2helper/types.go index f9dcd42..4daede3 100644 --- a/pkg/ec2helper/types.go +++ b/pkg/ec2helper/types.go @@ -37,6 +37,9 @@ type EC2Svc interface { RunInstances(input *ec2.RunInstancesInput) (*ec2.Reservation, error) TerminateInstances(input *ec2.TerminateInstancesInput) (*ec2.TerminateInstancesOutput, error) DeleteSecurityGroup(input *ec2.DeleteSecurityGroupInput) (*ec2.DeleteSecurityGroupOutput, error) + CreateLaunchTemplate(input *ec2.CreateLaunchTemplateInput) (*ec2.CreateLaunchTemplateOutput, error) + DeleteLaunchTemplate(input *ec2.DeleteLaunchTemplateInput) (*ec2.DeleteLaunchTemplateOutput, error) + CreateFleet(input *ec2.CreateFleetInput) (*ec2.CreateFleetOutput, error) } type EC2Helper struct { diff --git a/pkg/question/question.go b/pkg/question/question.go index 7758e59..e997bb5 100644 --- a/pkg/question/question.go +++ b/pkg/question/question.go @@ -17,6 +17,7 @@ import ( "bufio" "errors" "fmt" + "math" "os" "strconv" "strings" @@ -29,8 +30,10 @@ import ( "simple-ec2/pkg/iamhelper" "simple-ec2/pkg/table" + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/ec2pricing" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/endpoints" + "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/iam" "github.com/briandowns/spinner" @@ -38,6 +41,13 @@ import ( const yesNoOption = "[ yes / no ]" +var DefaultCapacityTypeText = struct { + OnDemand, Spot string +}{ + OnDemand: "On-Demand", + Spot: "Spot", +} + type CheckInput func(*ec2helper.EC2Helper, string) bool type AskQuestionInput struct { @@ -933,6 +943,7 @@ func AskConfirmationWithInput(simpleConfig *config.SimpleInfo, detailedConfig *c {cli.ResourceVpc, vpcInfo}, {cli.ResourceSubnet, subnetInfo}, {cli.ResourceInstanceType, simpleConfig.InstanceType}, + {cli.ResourceCapacityType, simpleConfig.CapacityType}, {cli.ResourceImage, simpleConfig.ImageId}, } @@ -1189,3 +1200,37 @@ func AskTerminationConfirmation(instanceIds []string) string { return answer } + +func AskCapacityType(instanceType string) string { + ec2Pricing := ec2pricing.New(session.New()) + onDemandPrice, err := ec2Pricing.GetOnDemandInstanceTypeCost(instanceType) + formattedOnDemandPrice := "" + if err == nil { + onDemandPrice = math.Round(onDemandPrice*10000) / 10000 + formattedOnDemandPrice = fmt.Sprintf("($%s/hr)", strconv.FormatFloat(onDemandPrice, 'f', -1, 64)) + } + + spotPrice, err := ec2Pricing.GetSpotInstanceTypeNDayAvgCost(instanceType, []string{}, 1) + formattedSpotPrice := "" + if err == nil { + spotPrice = math.Round(spotPrice*10000) / 10000 + formattedSpotPrice = fmt.Sprintf("($%s/hr)", strconv.FormatFloat(spotPrice, 'f', -1, 64)) + } + + question := fmt.Sprintf("Select capacity type. Spot instances are available at up to a 90%% discount compared to On-Demand instances,\n" + + "but they may get interrupted by EC2 with a 2-minute warning") + + defaultInstanceTypeText := DefaultCapacityTypeText.OnDemand + optionsText := fmt.Sprintf("1. On-Demand %s\n2. Spot %s\n", formattedOnDemandPrice, + formattedSpotPrice) + indexedOptions := []string{DefaultCapacityTypeText.OnDemand, DefaultCapacityTypeText.Spot} + + answer := AskQuestion(&AskQuestionInput{ + QuestionString: question, + DefaultOption: &defaultInstanceTypeText, + OptionsString: &optionsText, + IndexedOptions: indexedOptions, + }) + + return answer +} diff --git a/pkg/question/question_test.go b/pkg/question/question_test.go index bc92b99..07969c6 100644 --- a/pkg/question/question_test.go +++ b/pkg/question/question_test.go @@ -861,6 +861,7 @@ func TestAskConfirmationWithInput_Success_NewInfrastructure(t *testing.T) { testSimpleConfig.SecurityGroupIds = []string{cli.ResponseNew} testSimpleConfig.AutoTerminationTimerMinutes = 0 testSimpleConfig.SubnetId = "us-east-2" + testSimpleConfig.CapacityType = "Spot" testDetailedConfig.SecurityGroups = nil initQuestionTest(t, expectedAnswer+"\n") @@ -1116,6 +1117,16 @@ func TestAskIamProfile_Error(t *testing.T) { cleanupQuestionTest() } +func TestAskCapacityType(t *testing.T) { + expectedAnswer := question.DefaultCapacityTypeText.Spot + initQuestionTest(t, "2\n") + + answer := question.AskCapacityType(testInstanceType) + th.Equals(t, expectedAnswer, answer) + + cleanupQuestionTest() +} + func initQuestionTest(t *testing.T, input string) { err := th.TakeOverStdin(input) th.Ok(t, err) diff --git a/test/testhelper/ec2helper_mock.go b/test/testhelper/ec2helper_mock.go index 42e3021..8c2557d 100644 --- a/test/testhelper/ec2helper_mock.go +++ b/test/testhelper/ec2helper_mock.go @@ -374,6 +374,38 @@ func findFilter(filters []*ec2.Filter, name string) []*string { return nil } +func (e *MockedEC2Svc) CreateLaunchTemplate(input *ec2.CreateLaunchTemplateInput) (*ec2.CreateLaunchTemplateOutput, error) { + output := &ec2.CreateLaunchTemplateOutput{ + LaunchTemplate: &ec2.LaunchTemplate{ + LaunchTemplateId: aws.String("lt-12345"), + }, + } + e.LaunchTemplates = append(e.LaunchTemplates, output.LaunchTemplate) + return output, nil +} + +func (e *MockedEC2Svc) DeleteLaunchTemplate(input *ec2.DeleteLaunchTemplateInput) (*ec2.DeleteLaunchTemplateOutput, error) { + for index, template := range e.LaunchTemplates { + if *template.LaunchTemplateId == "lt-12345" { + e.LaunchTemplates = append(e.LaunchTemplates[:index], e.LaunchTemplates[index+1:]...) + return nil, nil + } + } + return nil, nil +} + +func (e *MockedEC2Svc) CreateFleet(input *ec2.CreateFleetInput) (*ec2.CreateFleetOutput, error) { + output := &ec2.CreateFleetOutput{ + Instances: []*ec2.CreateFleetInstance{ + { + InstanceIds: []*string{aws.String("i-12345")}, + Lifecycle: aws.String("spot"), + }, + }, + } + return output, nil +} + // Placeholder functions func (e *MockedEC2Svc) DeleteSecurityGroup(input *ec2.DeleteSecurityGroupInput) (*ec2.DeleteSecurityGroupOutput, error) { return nil, nil