From 112724fc3910baed280965fabf579269c180e7b0 Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Fri, 5 Jun 2015 10:12:09 -0500 Subject: [PATCH] provider/aws: spot_instance_request This is an iteration on the great work done by @dalehamel in PRs #2095 and #2109. The core team went back and forth on how to best model Spot Instance Requests, requesting and then rejecting a separate-resource implementation in #2109. After more internal discussion, we landed once again on a separate resource to model Spot Instance Requests. Out of respect for @dalehamel's already-significant donated time, with this I'm attempting to pick up the work to take this across the finish line. Important architectural decisions represented here: * Spot Instance Requests are always of type "persistent", to properly match Terraform's declarative model. * The spot_instance_request resource exports several attributes that are expected to be constantly changing as the spot market changes: spot_bid_status, spot_request_state, and instance_id. Creating additional resource dependencies based on these attributes is not recommended, as Terraform diffs will be continually generated to keep up with the live changes. * When a Spot Instance Request is deleted/canceled, an attempt is made to terminate the last-known attached spot instance. Race conditions dictate that this attempt cannot guarantee that the associated spot instance is terminated immediately. Implementation notes: * This version of aws_spot_instance_request borrows a lot of common code from aws_instance. * In order to facilitate borrowing, we introduce `awsInstanceOpts`, an internal representation of instance details that's meant to be shared between resources. The goal here would be to refactor ASG Launch Configurations to use the same struct. * The new aws_spot_instance_request acc. test is passing. * All aws_instance acc. tests remain passing. --- builtin/providers/aws/provider.go | 1 + .../providers/aws/resource_aws_instance.go | 506 ++++++++++-------- .../aws/resource_aws_spot_instance_request.go | 240 +++++++++ ...resource_aws_spot_instance_request_test.go | 158 ++++++ .../aws/r/spot_instance_request.html.markdown | 71 +++ website/source/layouts/aws.erb | 4 + 6 files changed, 754 insertions(+), 226 deletions(-) create mode 100644 builtin/providers/aws/resource_aws_spot_instance_request.go create mode 100644 builtin/providers/aws/resource_aws_spot_instance_request_test.go create mode 100644 website/source/docs/providers/aws/r/spot_instance_request.html.markdown diff --git a/builtin/providers/aws/provider.go b/builtin/providers/aws/provider.go index de2d9becc854..7e2de224d148 100644 --- a/builtin/providers/aws/provider.go +++ b/builtin/providers/aws/provider.go @@ -128,6 +128,7 @@ func Provider() terraform.ResourceProvider { "aws_s3_bucket": resourceAwsS3Bucket(), "aws_security_group": resourceAwsSecurityGroup(), "aws_security_group_rule": resourceAwsSecurityGroupRule(), + "aws_spot_instance_request": resourceAwsSpotInstanceRequest(), "aws_sqs_queue": resourceAwsSqsQueue(), "aws_sns_topic": resourceAwsSnsTopic(), "aws_sns_topic_subscription": resourceAwsSnsTopicSubscription(), diff --git a/builtin/providers/aws/resource_aws_instance.go b/builtin/providers/aws/resource_aws_instance.go index c9074fb5b6a4..945d72b8a971 100644 --- a/builtin/providers/aws/resource_aws_instance.go +++ b/builtin/providers/aws/resource_aws_instance.go @@ -12,6 +12,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/awsutil" "github.com/aws/aws-sdk-go/service/ec2" "github.com/hashicorp/terraform/helper/hashcode" "github.com/hashicorp/terraform/helper/resource" @@ -37,8 +38,9 @@ func resourceAwsInstance() *schema.Resource { "associate_public_ip_address": &schema.Schema{ Type: schema.TypeBool, - Optional: true, + Default: false, ForceNew: true, + Optional: true, }, "availability_zone": &schema.Schema{ @@ -313,214 +315,32 @@ func resourceAwsInstance() *schema.Resource { func resourceAwsInstanceCreate(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).ec2conn - // Figure out user data - userData := "" - if v := d.Get("user_data"); v != nil { - userData = base64.StdEncoding.EncodeToString([]byte(v.(string))) - } - - // check for non-default Subnet, and cast it to a String - var hasSubnet bool - subnet, hasSubnet := d.GetOk("subnet_id") - subnetID := subnet.(string) - - placement := &ec2.Placement{ - AvailabilityZone: aws.String(d.Get("availability_zone").(string)), - GroupName: aws.String(d.Get("placement_group").(string)), - } - - if hasSubnet { - // Tenancy is only valid inside a VPC - // See http://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_Placement.html - if v := d.Get("tenancy").(string); v != "" { - placement.Tenancy = aws.String(v) - } - } - - iam := &ec2.IAMInstanceProfileSpecification{ - Name: aws.String(d.Get("iam_instance_profile").(string)), + instanceOpts, err := buildAwsInstanceOpts(d, meta) + if err != nil { + return err } // Build the creation struct runOpts := &ec2.RunInstancesInput{ - ImageID: aws.String(d.Get("ami").(string)), - Placement: placement, - InstanceType: aws.String(d.Get("instance_type").(string)), + BlockDeviceMappings: instanceOpts.BlockDeviceMappings, + DisableAPITermination: instanceOpts.DisableAPITermination, + EBSOptimized: instanceOpts.EBSOptimized, + IAMInstanceProfile: instanceOpts.IAMInstanceProfile, + ImageID: instanceOpts.ImageID, + InstanceType: instanceOpts.InstanceType, MaxCount: aws.Long(int64(1)), MinCount: aws.Long(int64(1)), - UserData: aws.String(userData), - EBSOptimized: aws.Boolean(d.Get("ebs_optimized").(bool)), - DisableAPITermination: aws.Boolean(d.Get("disable_api_termination").(bool)), - IAMInstanceProfile: iam, - } - - associatePublicIPAddress := false - if v := d.Get("associate_public_ip_address"); v != nil { - associatePublicIPAddress = v.(bool) - } - - var groups []*string - if v := d.Get("security_groups"); v != nil { - // Security group names. - // For a nondefault VPC, you must use security group IDs instead. - // See http://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_RunInstances.html - sgs := v.(*schema.Set).List() - if len(sgs) > 0 && hasSubnet { - log.Printf("[WARN] Deprecated. Attempting to use 'security_groups' within a VPC instance. Use 'vpc_security_group_ids' instead.") - } - for _, v := range sgs { - str := v.(string) - groups = append(groups, aws.String(str)) - } - } - - if hasSubnet && associatePublicIPAddress { - // If we have a non-default VPC / Subnet specified, we can flag - // AssociatePublicIpAddress to get a Public IP assigned. By default these are not provided. - // You cannot specify both SubnetId and the NetworkInterface.0.* parameters though, otherwise - // you get: Network interfaces and an instance-level subnet ID may not be specified on the same request - // You also need to attach Security Groups to the NetworkInterface instead of the instance, - // to avoid: Network interfaces and an instance-level security groups may not be specified on - // the same request - ni := &ec2.InstanceNetworkInterfaceSpecification{ - AssociatePublicIPAddress: aws.Boolean(associatePublicIPAddress), - DeviceIndex: aws.Long(int64(0)), - SubnetID: aws.String(subnetID), - Groups: groups, - } - - if v, ok := d.GetOk("private_ip"); ok { - ni.PrivateIPAddress = aws.String(v.(string)) - } - - if v := d.Get("vpc_security_group_ids"); v != nil { - for _, v := range v.(*schema.Set).List() { - ni.Groups = append(ni.Groups, aws.String(v.(string))) - } - } - - runOpts.NetworkInterfaces = []*ec2.InstanceNetworkInterfaceSpecification{ni} - } else { - if subnetID != "" { - runOpts.SubnetID = aws.String(subnetID) - } - - if v, ok := d.GetOk("private_ip"); ok { - runOpts.PrivateIPAddress = aws.String(v.(string)) - } - if runOpts.SubnetID != nil && - *runOpts.SubnetID != "" { - runOpts.SecurityGroupIDs = groups - } else { - runOpts.SecurityGroups = groups - } - - if v := d.Get("vpc_security_group_ids"); v != nil { - for _, v := range v.(*schema.Set).List() { - runOpts.SecurityGroupIDs = append(runOpts.SecurityGroupIDs, aws.String(v.(string))) - } - } - } - - if v, ok := d.GetOk("key_name"); ok { - runOpts.KeyName = aws.String(v.(string)) - } - - blockDevices := make([]*ec2.BlockDeviceMapping, 0) - - if v, ok := d.GetOk("ebs_block_device"); ok { - vL := v.(*schema.Set).List() - for _, v := range vL { - bd := v.(map[string]interface{}) - ebs := &ec2.EBSBlockDevice{ - DeleteOnTermination: aws.Boolean(bd["delete_on_termination"].(bool)), - } - - if v, ok := bd["snapshot_id"].(string); ok && v != "" { - ebs.SnapshotID = aws.String(v) - } - - if v, ok := bd["encrypted"].(bool); ok && v { - ebs.Encrypted = aws.Boolean(v) - } - - if v, ok := bd["volume_size"].(int); ok && v != 0 { - ebs.VolumeSize = aws.Long(int64(v)) - } - - if v, ok := bd["volume_type"].(string); ok && v != "" { - ebs.VolumeType = aws.String(v) - } - - if v, ok := bd["iops"].(int); ok && v > 0 { - ebs.IOPS = aws.Long(int64(v)) - } - - blockDevices = append(blockDevices, &ec2.BlockDeviceMapping{ - DeviceName: aws.String(bd["device_name"].(string)), - EBS: ebs, - }) - } - } - - if v, ok := d.GetOk("ephemeral_block_device"); ok { - vL := v.(*schema.Set).List() - for _, v := range vL { - bd := v.(map[string]interface{}) - blockDevices = append(blockDevices, &ec2.BlockDeviceMapping{ - DeviceName: aws.String(bd["device_name"].(string)), - VirtualName: aws.String(bd["virtual_name"].(string)), - }) - } - } - - if v, ok := d.GetOk("root_block_device"); ok { - vL := v.(*schema.Set).List() - if len(vL) > 1 { - return fmt.Errorf("Cannot specify more than one root_block_device.") - } - for _, v := range vL { - bd := v.(map[string]interface{}) - ebs := &ec2.EBSBlockDevice{ - DeleteOnTermination: aws.Boolean(bd["delete_on_termination"].(bool)), - } - - if v, ok := bd["volume_size"].(int); ok && v != 0 { - ebs.VolumeSize = aws.Long(int64(v)) - } - - if v, ok := bd["volume_type"].(string); ok && v != "" { - ebs.VolumeType = aws.String(v) - } - - if v, ok := bd["iops"].(int); ok && v > 0 { - ebs.IOPS = aws.Long(int64(v)) - } - - if dn, err := fetchRootDeviceName(d.Get("ami").(string), conn); err == nil { - if dn == nil { - return fmt.Errorf( - "Expected 1 AMI for ID: %s, got none", - d.Get("ami").(string)) - } - - blockDevices = append(blockDevices, &ec2.BlockDeviceMapping{ - DeviceName: dn, - EBS: ebs, - }) - } else { - return err - } - } - } - - if len(blockDevices) > 0 { - runOpts.BlockDeviceMappings = blockDevices + NetworkInterfaces: instanceOpts.NetworkInterfaces, + Placement: instanceOpts.Placement, + PrivateIPAddress: instanceOpts.PrivateIPAddress, + SecurityGroupIDs: instanceOpts.SecurityGroupIDs, + SecurityGroups: instanceOpts.SecurityGroups, + SubnetID: instanceOpts.SubnetID, + UserData: instanceOpts.UserData64, } // Create the instance - log.Printf("[DEBUG] Run configuration: %#v", runOpts) - var err error + log.Printf("[DEBUG] Run configuration: %s", awsutil.StringValue(runOpts)) var runResp *ec2.Reservation for i := 0; i < 5; i++ { @@ -756,32 +576,8 @@ func resourceAwsInstanceUpdate(d *schema.ResourceData, meta interface{}) error { func resourceAwsInstanceDelete(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).ec2conn - log.Printf("[INFO] Terminating instance: %s", d.Id()) - req := &ec2.TerminateInstancesInput{ - InstanceIDs: []*string{aws.String(d.Id())}, - } - if _, err := conn.TerminateInstances(req); err != nil { - return fmt.Errorf("Error terminating instance: %s", err) - } - - log.Printf( - "[DEBUG] Waiting for instance (%s) to become terminated", - d.Id()) - - stateConf := &resource.StateChangeConf{ - Pending: []string{"pending", "running", "shutting-down", "stopped", "stopping"}, - Target: "terminated", - Refresh: InstanceStateRefreshFunc(conn, d.Id()), - Timeout: 10 * time.Minute, - Delay: 10 * time.Second, - MinTimeout: 3 * time.Second, - } - - _, err := stateConf.WaitForState() - if err != nil { - return fmt.Errorf( - "Error waiting for instance (%s) to terminate: %s", - d.Id(), err) + if err := awsTerminateInstance(conn, d.Id()); err != nil { + return err } d.SetId("") @@ -926,3 +722,261 @@ func fetchRootDeviceName(ami string, conn *ec2.EC2) (*string, error) { return nil, err } } + +func readBlockDeviceMappingsFromConfig( + d *schema.ResourceData, conn *ec2.EC2) ([]*ec2.BlockDeviceMapping, error) { + blockDevices := make([]*ec2.BlockDeviceMapping, 0) + + if v, ok := d.GetOk("ebs_block_device"); ok { + vL := v.(*schema.Set).List() + for _, v := range vL { + bd := v.(map[string]interface{}) + ebs := &ec2.EBSBlockDevice{ + DeleteOnTermination: aws.Boolean(bd["delete_on_termination"].(bool)), + } + + if v, ok := bd["snapshot_id"].(string); ok && v != "" { + ebs.SnapshotID = aws.String(v) + } + + if v, ok := bd["encrypted"].(bool); ok && v { + ebs.Encrypted = aws.Boolean(v) + } + + if v, ok := bd["volume_size"].(int); ok && v != 0 { + ebs.VolumeSize = aws.Long(int64(v)) + } + + if v, ok := bd["volume_type"].(string); ok && v != "" { + ebs.VolumeType = aws.String(v) + } + + if v, ok := bd["iops"].(int); ok && v > 0 { + ebs.IOPS = aws.Long(int64(v)) + } + + blockDevices = append(blockDevices, &ec2.BlockDeviceMapping{ + DeviceName: aws.String(bd["device_name"].(string)), + EBS: ebs, + }) + } + } + + if v, ok := d.GetOk("ephemeral_block_device"); ok { + vL := v.(*schema.Set).List() + for _, v := range vL { + bd := v.(map[string]interface{}) + blockDevices = append(blockDevices, &ec2.BlockDeviceMapping{ + DeviceName: aws.String(bd["device_name"].(string)), + VirtualName: aws.String(bd["virtual_name"].(string)), + }) + } + } + + if v, ok := d.GetOk("root_block_device"); ok { + vL := v.(*schema.Set).List() + if len(vL) > 1 { + return nil, fmt.Errorf("Cannot specify more than one root_block_device.") + } + for _, v := range vL { + bd := v.(map[string]interface{}) + ebs := &ec2.EBSBlockDevice{ + DeleteOnTermination: aws.Boolean(bd["delete_on_termination"].(bool)), + } + + if v, ok := bd["volume_size"].(int); ok && v != 0 { + ebs.VolumeSize = aws.Long(int64(v)) + } + + if v, ok := bd["volume_type"].(string); ok && v != "" { + ebs.VolumeType = aws.String(v) + } + + if v, ok := bd["iops"].(int); ok && v > 0 { + ebs.IOPS = aws.Long(int64(v)) + } + + if dn, err := fetchRootDeviceName(d.Get("ami").(string), conn); err == nil { + if dn == nil { + return nil, fmt.Errorf( + "Expected 1 AMI for ID: %s, got none", + d.Get("ami").(string)) + } + + blockDevices = append(blockDevices, &ec2.BlockDeviceMapping{ + DeviceName: dn, + EBS: ebs, + }) + } else { + return nil, err + } + } + } + + return blockDevices, nil +} + +type awsInstanceOpts struct { + BlockDeviceMappings []*ec2.BlockDeviceMapping + DisableAPITermination *bool + EBSOptimized *bool + IAMInstanceProfile *ec2.IAMInstanceProfileSpecification + ImageID *string + InstanceType *string + KeyName *string + NetworkInterfaces []*ec2.InstanceNetworkInterfaceSpecification + Placement *ec2.Placement + PrivateIPAddress *string + SecurityGroupIDs []*string + SecurityGroups []*string + SpotPlacement *ec2.SpotPlacement + SubnetID *string + UserData64 *string +} + +func buildAwsInstanceOpts( + d *schema.ResourceData, meta interface{}) (*awsInstanceOpts, error) { + conn := meta.(*AWSClient).ec2conn + + opts := &awsInstanceOpts{ + DisableAPITermination: aws.Boolean(d.Get("disable_api_termination").(bool)), + EBSOptimized: aws.Boolean(d.Get("ebs_optimized").(bool)), + ImageID: aws.String(d.Get("ami").(string)), + InstanceType: aws.String(d.Get("instance_type").(string)), + } + + opts.IAMInstanceProfile = &ec2.IAMInstanceProfileSpecification{ + Name: aws.String(d.Get("iam_instance_profile").(string)), + } + + opts.UserData64 = aws.String( + base64.StdEncoding.EncodeToString([]byte(d.Get("user_data").(string)))) + + // check for non-default Subnet, and cast it to a String + subnet, hasSubnet := d.GetOk("subnet_id") + subnetID := subnet.(string) + + // Placement is used for aws_instance; SpotPlacement is used for + // aws_spot_instance_request. They represent the same data. :-| + opts.Placement = &ec2.Placement{ + AvailabilityZone: aws.String(d.Get("availability_zone").(string)), + GroupName: aws.String(d.Get("placement_group").(string)), + } + + opts.SpotPlacement = &ec2.SpotPlacement{ + AvailabilityZone: aws.String(d.Get("availability_zone").(string)), + GroupName: aws.String(d.Get("placement_group").(string)), + } + + if v := d.Get("tenancy").(string); v != "" { + opts.Placement.Tenancy = aws.String(v) + } + + associatePublicIPAddress := d.Get("associate_public_ip_address").(bool) + + var groups []*string + if v := d.Get("security_groups"); v != nil { + // Security group names. + // For a nondefault VPC, you must use security group IDs instead. + // See http://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_RunInstances.html + sgs := v.(*schema.Set).List() + if len(sgs) > 0 && hasSubnet { + log.Printf("[WARN] Deprecated. Attempting to use 'security_groups' within a VPC instance. Use 'vpc_security_group_ids' instead.") + } + for _, v := range sgs { + str := v.(string) + groups = append(groups, aws.String(str)) + } + } + + if hasSubnet && associatePublicIPAddress { + // If we have a non-default VPC / Subnet specified, we can flag + // AssociatePublicIpAddress to get a Public IP assigned. By default these are not provided. + // You cannot specify both SubnetId and the NetworkInterface.0.* parameters though, otherwise + // you get: Network interfaces and an instance-level subnet ID may not be specified on the same request + // You also need to attach Security Groups to the NetworkInterface instead of the instance, + // to avoid: Network interfaces and an instance-level security groups may not be specified on + // the same request + ni := &ec2.InstanceNetworkInterfaceSpecification{ + AssociatePublicIPAddress: aws.Boolean(associatePublicIPAddress), + DeviceIndex: aws.Long(int64(0)), + SubnetID: aws.String(subnetID), + Groups: groups, + } + + if v, ok := d.GetOk("private_ip"); ok { + ni.PrivateIPAddress = aws.String(v.(string)) + } + + if v := d.Get("vpc_security_group_ids"); v != nil { + for _, v := range v.(*schema.Set).List() { + ni.Groups = append(ni.Groups, aws.String(v.(string))) + } + } + + opts.NetworkInterfaces = []*ec2.InstanceNetworkInterfaceSpecification{ni} + } else { + if subnetID != "" { + opts.SubnetID = aws.String(subnetID) + } + + if v, ok := d.GetOk("private_ip"); ok { + opts.PrivateIPAddress = aws.String(v.(string)) + } + if opts.SubnetID != nil && + *opts.SubnetID != "" { + opts.SecurityGroupIDs = groups + } else { + opts.SecurityGroups = groups + } + + if v := d.Get("vpc_security_group_ids"); v != nil { + for _, v := range v.(*schema.Set).List() { + opts.SecurityGroupIDs = append(opts.SecurityGroupIDs, aws.String(v.(string))) + } + } + } + + if v, ok := d.GetOk("key_name"); ok { + opts.KeyName = aws.String(v.(string)) + } + + blockDevices, err := readBlockDeviceMappingsFromConfig(d, conn) + if err != nil { + return nil, err + } + if len(blockDevices) > 0 { + opts.BlockDeviceMappings = blockDevices + } + + return opts, nil +} + +func awsTerminateInstance(conn *ec2.EC2, id string) error { + log.Printf("[INFO] Terminating instance: %s", id) + req := &ec2.TerminateInstancesInput{ + InstanceIDs: []*string{aws.String(id)}, + } + if _, err := conn.TerminateInstances(req); err != nil { + return fmt.Errorf("Error terminating instance: %s", err) + } + + log.Printf("[DEBUG] Waiting for instance (%s) to become terminated", id) + + stateConf := &resource.StateChangeConf{ + Pending: []string{"pending", "running", "shutting-down", "stopped", "stopping"}, + Target: "terminated", + Refresh: InstanceStateRefreshFunc(conn, id), + Timeout: 10 * time.Minute, + Delay: 10 * time.Second, + MinTimeout: 3 * time.Second, + } + + _, err := stateConf.WaitForState() + if err != nil { + return fmt.Errorf( + "Error waiting for instance (%s) to terminate: %s", id, err) + } + + return nil +} diff --git a/builtin/providers/aws/resource_aws_spot_instance_request.go b/builtin/providers/aws/resource_aws_spot_instance_request.go new file mode 100644 index 000000000000..05b149fe2a4f --- /dev/null +++ b/builtin/providers/aws/resource_aws_spot_instance_request.go @@ -0,0 +1,240 @@ +package aws + +import ( + "fmt" + "log" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/awsutil" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceAwsSpotInstanceRequest() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsSpotInstanceRequestCreate, + Read: resourceAwsSpotInstanceRequestRead, + Delete: resourceAwsSpotInstanceRequestDelete, + Update: resourceAwsSpotInstanceRequestUpdate, + + Schema: func() map[string]*schema.Schema { + // The Spot Instance Request Schema is based on the AWS Instance schema. + s := resourceAwsInstance().Schema + + // Everything on a spot instance is ForceNew except tags + for k, v := range s { + if k == "tags" { + continue + } + v.ForceNew = true + } + + s["spot_price"] = &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + } + s["wait_for_fulfillment"] = &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + Default: false, + } + s["spot_bid_status"] = &schema.Schema{ + Type: schema.TypeString, + Computed: true, + } + s["spot_request_state"] = &schema.Schema{ + Type: schema.TypeString, + Computed: true, + } + s["spot_instance_id"] = &schema.Schema{ + Type: schema.TypeString, + Computed: true, + } + + return s + }(), + } +} + +func resourceAwsSpotInstanceRequestCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).ec2conn + + instanceOpts, err := buildAwsInstanceOpts(d, meta) + if err != nil { + return err + } + + spotOpts := &ec2.RequestSpotInstancesInput{ + SpotPrice: aws.String(d.Get("spot_price").(string)), + + // We always set the type to "persistent", since the imperative-like + // behavior of "one-time" does not map well to TF's declarative domain. + Type: aws.String("persistent"), + + // Though the AWS API supports creating spot instance requests for multiple + // instances, for TF purposes we fix this to one instance per request. + // Users can get equivalent behavior out of TF's "count" meta-parameter. + InstanceCount: aws.Long(1), + + LaunchSpecification: &ec2.RequestSpotLaunchSpecification{ + BlockDeviceMappings: instanceOpts.BlockDeviceMappings, + EBSOptimized: instanceOpts.EBSOptimized, + IAMInstanceProfile: instanceOpts.IAMInstanceProfile, + ImageID: instanceOpts.ImageID, + InstanceType: instanceOpts.InstanceType, + Placement: instanceOpts.SpotPlacement, + SecurityGroupIDs: instanceOpts.SecurityGroupIDs, + SecurityGroups: instanceOpts.SecurityGroups, + UserData: instanceOpts.UserData64, + }, + } + + // Make the spot instance request + resp, err := conn.RequestSpotInstances(spotOpts) + if err != nil { + return fmt.Errorf("Error requesting spot instances: %s", err) + } + if len(resp.SpotInstanceRequests) != 1 { + return fmt.Errorf( + "Expected response with length 1, got: %s", awsutil.StringValue(resp)) + } + + sir := *resp.SpotInstanceRequests[0] + d.SetId(*sir.SpotInstanceRequestID) + + if d.Get("wait_for_fulfillment").(bool) { + spotStateConf := &resource.StateChangeConf{ + // http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-bid-status.html + Pending: []string{"start", "pending-evaluation", "pending-fulfillment"}, + Target: "fulfilled", + Refresh: SpotInstanceStateRefreshFunc(conn, sir), + Timeout: 10 * time.Minute, + Delay: 10 * time.Second, + MinTimeout: 3 * time.Second, + } + + log.Printf("[DEBUG] waiting for spot bid to resolve... this may take several minutes.") + _, err = spotStateConf.WaitForState() + + if err != nil { + return fmt.Errorf("Error while waiting for spot request (%s) to resolve: %s", awsutil.StringValue(sir), err) + } + } + + return resourceAwsSpotInstanceRequestUpdate(d, meta) +} + +// Update spot state, etc +func resourceAwsSpotInstanceRequestRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).ec2conn + + req := &ec2.DescribeSpotInstanceRequestsInput{ + SpotInstanceRequestIDs: []*string{aws.String(d.Id())}, + } + resp, err := conn.DescribeSpotInstanceRequests(req) + + if err != nil { + // If the spot request was not found, return nil so that we can show + // that it is gone. + if ec2err, ok := err.(awserr.Error); ok && ec2err.Code() == "InvalidSpotInstanceRequestID.NotFound" { + d.SetId("") + return nil + } + + // Some other error, report it + return err + } + + // If nothing was found, then return no state + if len(resp.SpotInstanceRequests) == 0 { + d.SetId("") + return nil + } + + request := resp.SpotInstanceRequests[0] + + // if the request is cancelled, then it is gone + if *request.State == "canceled" { + d.SetId("") + return nil + } + + d.Set("spot_bid_status", *request.Status.Code) + d.Set("spot_instance_id", *request.InstanceID) + d.Set("spot_request_state", *request.State) + d.Set("tags", tagsToMap(request.Tags)) + + return nil +} + +func resourceAwsSpotInstanceRequestUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).ec2conn + + d.Partial(true) + if err := setTags(conn, d); err != nil { + return err + } else { + d.SetPartial("tags") + } + + d.Partial(false) + + return resourceAwsInstanceRead(d, meta) +} + +func resourceAwsSpotInstanceRequestDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).ec2conn + + log.Printf("[INFO] Cancelling spot request: %s", d.Id()) + _, err := conn.CancelSpotInstanceRequests(&ec2.CancelSpotInstanceRequestsInput{ + SpotInstanceRequestIDs: []*string{aws.String(d.Id())}, + }) + + if err != nil { + return fmt.Errorf("Error cancelling spot request (%s): %s", d.Id(), err) + } + + if instanceId := d.Get("spot_instance_id").(string); instanceId != "" { + log.Printf("[INFO] Terminating instance: %s", instanceId) + if err := awsTerminateInstance(conn, instanceId); err != nil { + return fmt.Errorf("Error terminating spot instance: %s", err) + } + } + + return nil +} + +// SpotInstanceStateRefreshFunc returns a resource.StateRefreshFunc that is used to watch +// an EC2 spot instance request +func SpotInstanceStateRefreshFunc( + conn *ec2.EC2, sir ec2.SpotInstanceRequest) resource.StateRefreshFunc { + + return func() (interface{}, string, error) { + resp, err := conn.DescribeSpotInstanceRequests(&ec2.DescribeSpotInstanceRequestsInput{ + SpotInstanceRequestIDs: []*string{sir.SpotInstanceRequestID}, + }) + + if err != nil { + if ec2err, ok := err.(awserr.Error); ok && ec2err.Code() == "InvalidSpotInstanceRequestID.NotFound" { + // Set this to nil as if we didn't find anything. + resp = nil + } else { + log.Printf("Error on StateRefresh: %s", err) + return nil, "", err + } + } + + if resp == nil || len(resp.SpotInstanceRequests) == 0 { + // Sometimes AWS just has consistency issues and doesn't see + // our request yet. Return an empty state. + return nil, "", nil + } + + req := resp.SpotInstanceRequests[0] + return req, *req.Status.Code, nil + } +} diff --git a/builtin/providers/aws/resource_aws_spot_instance_request_test.go b/builtin/providers/aws/resource_aws_spot_instance_request_test.go new file mode 100644 index 000000000000..0524f325771b --- /dev/null +++ b/builtin/providers/aws/resource_aws_spot_instance_request_test.go @@ -0,0 +1,158 @@ +package aws + +import ( + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccAWSSpotInstanceRequest_basic(t *testing.T) { + var sir ec2.SpotInstanceRequest + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSSpotInstanceRequestDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAWSSpotInstanceRequestConfig, + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSSpotInstanceRequestExists( + "aws_spot_instance_request.foo", &sir), + testAccCheckAWSSpotInstanceRequestAttributes(&sir), + resource.TestCheckResourceAttr( + "aws_spot_instance_request.foo", "spot_bid_status", "fulfilled"), + resource.TestCheckResourceAttr( + "aws_spot_instance_request.foo", "spot_request_state", "active"), + ), + }, + }, + }) +} + +func testAccCheckAWSSpotInstanceRequestDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).ec2conn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_spot_instance_request" { + continue + } + + req := &ec2.DescribeSpotInstanceRequestsInput{ + SpotInstanceRequestIDs: []*string{aws.String(rs.Primary.ID)}, + } + + resp, err := conn.DescribeSpotInstanceRequests(req) + if err == nil { + if len(resp.SpotInstanceRequests) > 0 { + return fmt.Errorf("Spot instance request is still here.") + } + } + + // Verify the error is what we expect + ec2err, ok := err.(awserr.Error) + if !ok { + return err + } + if ec2err.Code() != "InvalidSpotInstanceRequestID.NotFound" { + return err + } + + // Now check if the associated Spot Instance was also destroyed + instId := rs.Primary.Attributes["spot_instance_id"] + instResp, instErr := conn.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIDs: []*string{aws.String(instId)}, + }) + if instErr == nil { + if len(instResp.Reservations) > 0 { + return fmt.Errorf("Instance still exists.") + } + + return nil + } + + // Verify the error is what we expect + ec2err, ok = err.(awserr.Error) + if !ok { + return err + } + if ec2err.Code() != "InvalidInstanceID.NotFound" { + return err + } + } + + return nil +} + +func testAccCheckAWSSpotInstanceRequestExists( + n string, sir *ec2.SpotInstanceRequest) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No SNS subscription with that ARN exists") + } + + conn := testAccProvider.Meta().(*AWSClient).ec2conn + + params := &ec2.DescribeSpotInstanceRequestsInput{ + SpotInstanceRequestIDs: []*string{&rs.Primary.ID}, + } + resp, err := conn.DescribeSpotInstanceRequests(params) + + if err != nil { + return err + } + + if v := len(resp.SpotInstanceRequests); v != 1 { + return fmt.Errorf("Expected 1 request returned, got %d", v) + } + + *sir = *resp.SpotInstanceRequests[0] + + return nil + } +} + +func testAccCheckAWSSpotInstanceRequestAttributes( + sir *ec2.SpotInstanceRequest) resource.TestCheckFunc { + return func(s *terraform.State) error { + if *sir.SpotPrice != "0.050000" { + return fmt.Errorf("Unexpected spot price: %s", *sir.SpotPrice) + } + if *sir.State != "active" { + return fmt.Errorf("Unexpected request state: %s", *sir.State) + } + if *sir.Status.Code != "fulfilled" { + return fmt.Errorf("Unexpected bid status: %s", *sir.State) + } + return nil + } +} + +const testAccAWSSpotInstanceRequestConfig = ` +resource "aws_spot_instance_request" "foo" { + ami = "ami-4fccb37f" + instance_type = "m1.small" + + // base price is $0.044 hourly, so bidding above that should theoretically + // always fulfill + spot_price = "0.05" + + // we wait for fulfillment because we want to inspect the launched instance + // and verify termination behavior + wait_for_fulfillment = true + + tags { + Name = "terraform-test" + } +} +` diff --git a/website/source/docs/providers/aws/r/spot_instance_request.html.markdown b/website/source/docs/providers/aws/r/spot_instance_request.html.markdown new file mode 100644 index 000000000000..abb1f4705efb --- /dev/null +++ b/website/source/docs/providers/aws/r/spot_instance_request.html.markdown @@ -0,0 +1,71 @@ +--- +layout: "aws" +page_title: "AWS: aws_spot_instance_request" +sidebar_current: "docs-aws-resource-spot-instance-request" +description: |- + Provides a Spot Instance Request resource. +--- + +# aws\_spot\_instance\_request + +Provides an EC2 Spot Instance Request resource. This allows instances to be +requested on the spot market. + +Terraform always creates Spot Instance Requests with a `persistent` type, which +means that for the duration of their lifetime, AWS will launch an instance +with the configured details if and when the spot market will accept the +requested price. + +On destruction, Terraform will make an attempt to terminate the associated Spot +Instance if there is one present. + +~> **NOTE:** Because their behavior depends on the live status of the spot +market, Spot Instance Requests have a unique lifecycle that makes them behave +differently than other Terraform resources. Most importantly: there is __no +guarantee__ that a Spot Instance exists to fulfill the request at any given +point in time. See the [AWS Spot Instance +documentation](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-spot-instances.html) +for more information. + + +## Example Usage + +``` +# Request a spot instance at $0.03 +resource "aws_spot_instance_request" "cheap_worker" { + ami = "ami-1234" + spot_price = "0.03" + instance_type = "c4.xlarge" + tags { + Name = "CheapWorker" + } +} +``` + +## Argument Reference + +Spot Instance Requests support all the same arguments as +[`aws_instance`](instance.html), with the addition of: + +* `spot_price` - (Required) The price to request on the spot market. +* `wait_for_fulfillment` - (Optional; Default: false) If set, Terraform will + wait for the Spot Request to be fulfilled, and will throw an error if the + timeout of 10m is reached. + +## Attributes Reference + +The following attributes are exported: + +* `id` - The Spot Instance Request ID. + +These attributes are exported, but they are expected to change over time and so +should only be used for informational purposes, not for resource dependencies: + +* `spot_bid_status` - The current [bid + status](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-bid-status.html) + of the Spot Instance Request. +* `spot_request_state` The current [request + state](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-requests.html#creating-spot-request-status) + of the Spot Instance Request. +* `spot_instance_id` - The Instance ID (if any) that is currently fulfilling + the Spot Instance request. diff --git a/website/source/layouts/aws.erb b/website/source/layouts/aws.erb index cbb7eb1aa03c..aaf2e49a3256 100644 --- a/website/source/layouts/aws.erb +++ b/website/source/layouts/aws.erb @@ -197,6 +197,10 @@ aws_sns_topic_subscription + > + aws_spot_instance_request + + > aws_sqs_queue