From 7f6432766363588f4d196c79773ce39dad46543f Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Sat, 29 Aug 2015 21:35:45 -0700 Subject: [PATCH] Three resources for AWS AMIs. AWS provides three different ways to create AMIs that each have different inputs, but once they are complete the same management operations apply. Thus these three resources each have a different "Create" implementation but then share the same "Read", "Update" and "Delete" implementations. --- builtin/providers/aws/provider.go | 3 + builtin/providers/aws/resource_aws_ami.go | 521 ++++++++++++++++++ .../providers/aws/resource_aws_ami_copy.go | 70 +++ .../aws/resource_aws_ami_copy_test.go | 195 +++++++ .../aws/resource_aws_ami_from_instance.go | 70 +++ .../resource_aws_ami_from_instance_test.go | 157 ++++++ .../providers/aws/resource_aws_ami_test.go | 8 + .../docs/providers/aws/r/ami.html.markdown | 92 ++++ .../providers/aws/r/ami_copy.html.markdown | 51 ++ .../aws/r/ami_from_instance.html.markdown | 57 ++ website/source/layouts/aws.erb | 12 + 11 files changed, 1236 insertions(+) create mode 100644 builtin/providers/aws/resource_aws_ami.go create mode 100644 builtin/providers/aws/resource_aws_ami_copy.go create mode 100644 builtin/providers/aws/resource_aws_ami_copy_test.go create mode 100644 builtin/providers/aws/resource_aws_ami_from_instance.go create mode 100644 builtin/providers/aws/resource_aws_ami_from_instance_test.go create mode 100644 builtin/providers/aws/resource_aws_ami_test.go create mode 100644 website/source/docs/providers/aws/r/ami.html.markdown create mode 100644 website/source/docs/providers/aws/r/ami_copy.html.markdown create mode 100644 website/source/docs/providers/aws/r/ami_from_instance.html.markdown diff --git a/builtin/providers/aws/provider.go b/builtin/providers/aws/provider.go index a5029b400a65..6b2c16c7abdf 100644 --- a/builtin/providers/aws/provider.go +++ b/builtin/providers/aws/provider.go @@ -156,6 +156,9 @@ func Provider() terraform.ResourceProvider { }, ResourcesMap: map[string]*schema.Resource{ + "aws_ami": resourceAwsAmi(), + "aws_ami_copy": resourceAwsAmiCopy(), + "aws_ami_from_instance": resourceAwsAmiFromInstance(), "aws_app_cookie_stickiness_policy": resourceAwsAppCookieStickinessPolicy(), "aws_autoscaling_group": resourceAwsAutoscalingGroup(), "aws_autoscaling_notification": resourceAwsAutoscalingNotification(), diff --git a/builtin/providers/aws/resource_aws_ami.go b/builtin/providers/aws/resource_aws_ami.go new file mode 100644 index 000000000000..ec3ce73b97d0 --- /dev/null +++ b/builtin/providers/aws/resource_aws_ami.go @@ -0,0 +1,521 @@ +package aws + +import ( + "bytes" + "errors" + "fmt" + "log" + "strings" + "time" + + "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/hashcode" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceAwsAmi() *schema.Resource { + // Our schema is shared also with aws_ami_copy and aws_ami_from_instance + resourceSchema := resourceAwsAmiCommonSchema(false) + + return &schema.Resource{ + Create: resourceAwsAmiCreate, + + Schema: resourceSchema, + + // The Read, Update and Delete operations are shared with aws_ami_copy + // and aws_ami_from_instance, since they differ only in how the image + // is created. + Read: resourceAwsAmiRead, + Update: resourceAwsAmiUpdate, + Delete: resourceAwsAmiDelete, + } +} + +func resourceAwsAmiCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).ec2conn + + req := &ec2.RegisterImageInput{ + Name: aws.String(d.Get("name").(string)), + Description: aws.String(d.Get("description").(string)), + Architecture: aws.String(d.Get("architecture").(string)), + ImageLocation: aws.String(d.Get("image_location").(string)), + RootDeviceName: aws.String(d.Get("root_device_name").(string)), + SriovNetSupport: aws.String(d.Get("sriov_net_support").(string)), + VirtualizationType: aws.String(d.Get("virtualization_type").(string)), + } + + if kernelId := d.Get("kernel_id").(string); kernelId != "" { + req.KernelId = aws.String(kernelId) + } + if ramdiskId := d.Get("ramdisk_id").(string); ramdiskId != "" { + req.RamdiskId = aws.String(ramdiskId) + } + + ebsBlockDevsSet := d.Get("ebs_block_device").(*schema.Set) + ephemeralBlockDevsSet := d.Get("ephemeral_block_device").(*schema.Set) + for _, ebsBlockDevI := range ebsBlockDevsSet.List() { + ebsBlockDev := ebsBlockDevI.(map[string]interface{}) + blockDev := &ec2.BlockDeviceMapping{ + DeviceName: aws.String(ebsBlockDev["device_name"].(string)), + Ebs: &ec2.EbsBlockDevice{ + DeleteOnTermination: aws.Bool(ebsBlockDev["delete_on_termination"].(bool)), + VolumeSize: aws.Int64(int64(ebsBlockDev["volume_size"].(int))), + VolumeType: aws.String(ebsBlockDev["volume_type"].(string)), + }, + } + if iops := ebsBlockDev["iops"].(int); iops != 0 { + blockDev.Ebs.Iops = aws.Int64(int64(iops)) + } + encrypted := ebsBlockDev["encrypted"].(bool) + if snapshotId := ebsBlockDev["snapshot_id"].(string); snapshotId != "" { + blockDev.Ebs.SnapshotId = aws.String(snapshotId) + if encrypted { + return errors.New("can't set both 'snapshot_id' and 'encrypted'") + } + } else if encrypted { + blockDev.Ebs.Encrypted = aws.Bool(true) + } + req.BlockDeviceMappings = append(req.BlockDeviceMappings, blockDev) + } + for _, ephemeralBlockDevI := range ephemeralBlockDevsSet.List() { + ephemeralBlockDev := ephemeralBlockDevI.(map[string]interface{}) + blockDev := &ec2.BlockDeviceMapping{ + DeviceName: aws.String(ephemeralBlockDev["device_name"].(string)), + VirtualName: aws.String(ephemeralBlockDev["virtual_name"].(string)), + } + req.BlockDeviceMappings = append(req.BlockDeviceMappings, blockDev) + } + + res, err := client.RegisterImage(req) + if err != nil { + return err + } + + id := *res.ImageId + d.SetId(id) + d.Partial(true) // make sure we record the id even if the rest of this gets interrupted + d.Set("id", id) + d.Set("manage_ebs_block_devices", false) + d.SetPartial("id") + d.SetPartial("manage_ebs_block_devices") + d.Partial(false) + + _, err = resourceAwsAmiWaitForAvailable(id, client) + if err != nil { + return err + } + + return resourceAwsAmiUpdate(d, meta) +} + +func resourceAwsAmiRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).ec2conn + id := d.Id() + + req := &ec2.DescribeImagesInput{ + ImageIds: []*string{aws.String(id)}, + } + + res, err := client.DescribeImages(req) + if err != nil { + return err + } + + if len(res.Images) != 1 { + d.SetId("") + return nil + } + + image := res.Images[0] + state := *(image.State) + + if state == "pending" { + // This could happen if a user manually adds an image we didn't create + // to the state. We'll wait for the image to become available + // before we continue. We should never take this branch in normal + // circumstances since we would've waited for availability during + // the "Create" step. + image, err = resourceAwsAmiWaitForAvailable(id, client) + if err != nil { + return err + } + state = *(image.State) + } + + if state == "deregistered" { + d.SetId("") + return nil + } + + if state != "available" { + return fmt.Errorf("AMI has become %s", state) + } + + d.Set("name", image.Name) + d.Set("description", image.Description) + d.Set("image_location", image.ImageLocation) + d.Set("architecture", image.Architecture) + d.Set("kernel_id", image.KernelId) + d.Set("ramdisk_id", image.RamdiskId) + d.Set("root_device_name", image.RootDeviceName) + d.Set("sriov_net_support", image.SriovNetSupport) + d.Set("virtualization_type", image.VirtualizationType) + + var ebsBlockDevs []map[string]interface{} + var ephemeralBlockDevs []map[string]interface{} + + for _, blockDev := range image.BlockDeviceMappings { + if blockDev.Ebs != nil { + ebsBlockDev := map[string]interface{}{ + "device_name": *(blockDev.DeviceName), + "delete_on_termination": *(blockDev.Ebs.DeleteOnTermination), + "encrypted": *(blockDev.Ebs.Encrypted), + "iops": 0, + "snapshot_id": *(blockDev.Ebs.SnapshotId), + "volume_size": int(*(blockDev.Ebs.VolumeSize)), + "volume_type": *(blockDev.Ebs.VolumeType), + } + if blockDev.Ebs.Iops != nil { + ebsBlockDev["iops"] = int(*(blockDev.Ebs.Iops)) + } + ebsBlockDevs = append(ebsBlockDevs, ebsBlockDev) + } else { + ephemeralBlockDevs = append(ephemeralBlockDevs, map[string]interface{}{ + "device_name": *(blockDev.DeviceName), + "virtual_name": *(blockDev.VirtualName), + }) + } + } + + d.Set("ebs_block_device", ebsBlockDevs) + d.Set("ephemeral_block_device", ephemeralBlockDevs) + + d.Set("tags", tagsToMap(image.Tags)) + + return nil +} + +func resourceAwsAmiUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).ec2conn + + d.Partial(true) + + if err := setTags(client, d); err != nil { + return err + } else { + d.SetPartial("tags") + } + + if d.Get("description").(string) != "" { + _, err := client.ModifyImageAttribute(&ec2.ModifyImageAttributeInput{ + ImageId: aws.String(d.Id()), + Description: &ec2.AttributeValue{ + Value: aws.String(d.Get("description").(string)), + }, + }) + if err != nil { + return err + } + d.SetPartial("description") + } + + d.Partial(false) + + return resourceAwsAmiRead(d, meta) +} + +func resourceAwsAmiDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).ec2conn + + req := &ec2.DeregisterImageInput{ + ImageId: aws.String(d.Id()), + } + + _, err := client.DeregisterImage(req) + if err != nil { + return err + } + + // If we're managing the EBS snapshots then we need to delete those too. + if d.Get("manage_ebs_snapshots").(bool) { + errs := map[string]error{} + ebsBlockDevsSet := d.Get("ebs_block_device").(*schema.Set) + req := &ec2.DeleteSnapshotInput{} + for _, ebsBlockDevI := range ebsBlockDevsSet.List() { + ebsBlockDev := ebsBlockDevI.(map[string]interface{}) + snapshotId := ebsBlockDev["snapshot_id"].(string) + if snapshotId != "" { + req.SnapshotId = aws.String(snapshotId) + _, err := client.DeleteSnapshot(req) + if err != nil { + errs[snapshotId] = err + } + } + } + + if len(errs) > 0 { + errParts := []string{"Errors while deleting associated EBS snapshots:"} + for snapshotId, err := range errs { + errParts = append(errParts, fmt.Sprintf("%s: %s", snapshotId, err)) + } + errParts = append(errParts, "These are no longer managed by Terraform and must be deleted manually.") + return errors.New(strings.Join(errParts, "\n")) + } + } + + d.SetId("") + + return nil +} + +func resourceAwsAmiWaitForAvailable(id string, client *ec2.EC2) (*ec2.Image, error) { + log.Printf("Waiting for AMI %s to become available...", id) + + req := &ec2.DescribeImagesInput{ + ImageIds: []*string{aws.String(id)}, + } + pollsWhereNotFound := 0 + for { + res, err := client.DescribeImages(req) + if err != nil { + // When using RegisterImage (for aws_ami) the AMI sometimes isn't available at all + // right after the API responds, so we need to tolerate a couple Not Found errors + // before an available AMI shows up. + if ec2err, ok := err.(awserr.Error); ok && ec2err.Code() == "InvalidAMIID.NotFound" { + pollsWhereNotFound++ + // We arbitrarily stop polling after getting a "not found" error five times, + // assuming that the AMI has been deleted by something other than Terraform. + if pollsWhereNotFound > 5 { + return nil, fmt.Errorf("gave up waiting for AMI to be created: %s", err) + } + time.Sleep(4 * time.Second) + continue + } + return nil, fmt.Errorf("error reading AMI: %s", err) + } + + if len(res.Images) != 1 { + return nil, fmt.Errorf("new AMI vanished while pending") + } + + state := *(res.Images[0].State) + + if state == "pending" { + // Give it a few seconds before we poll again. + time.Sleep(4 * time.Second) + continue + } + + if state == "available" { + // We're done! + return res.Images[0], nil + } + + // If we're not pending or available then we're in one of the invalid/error + // states, so stop polling and bail out. + stateReason := *(res.Images[0].StateReason) + return nil, fmt.Errorf("new AMI became %s while pending: %s", state, stateReason) + } +} + +func resourceAwsAmiCommonSchema(computed bool) map[string]*schema.Schema { + // The "computed" parameter controls whether we're making + // a schema for an AMI that's been implicitly registered (aws_ami_copy, aws_ami_from_instance) + // or whether we're making a schema for an explicit registration (aws_ami). + // When set, almost every attribute is marked as "computed". + // When not set, only the "id" attribute is computed. + // "name" and "description" are never computed, since they must always + // be provided by the user. + + var virtualizationTypeDefault interface{} + var deleteEbsOnTerminationDefault interface{} + var sriovNetSupportDefault interface{} + var architectureDefault interface{} + var volumeTypeDefault interface{} + if !computed { + virtualizationTypeDefault = "paravirtual" + deleteEbsOnTerminationDefault = true + sriovNetSupportDefault = "simple" + architectureDefault = "x86_64" + volumeTypeDefault = "standard" + } + + return map[string]*schema.Schema{ + "id": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + "image_location": &schema.Schema{ + Type: schema.TypeString, + Optional: !computed, + Computed: true, + ForceNew: !computed, + }, + "architecture": &schema.Schema{ + Type: schema.TypeString, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + Default: architectureDefault, + }, + "description": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + }, + "kernel_id": &schema.Schema{ + Type: schema.TypeString, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + }, + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "ramdisk_id": &schema.Schema{ + Type: schema.TypeString, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + }, + "root_device_name": &schema.Schema{ + Type: schema.TypeString, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + }, + "sriov_net_support": &schema.Schema{ + Type: schema.TypeString, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + Default: sriovNetSupportDefault, + }, + "virtualization_type": &schema.Schema{ + Type: schema.TypeString, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + Default: virtualizationTypeDefault, + }, + + // The following block device attributes intentionally mimick the + // corresponding attributes on aws_instance, since they have the + // same meaning. + // However, we don't use root_block_device here because the constraint + // on which root device attributes can be overridden for an instance to + // not apply when registering an AMI. + + "ebs_block_device": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "delete_on_termination": &schema.Schema{ + Type: schema.TypeBool, + Optional: !computed, + Default: deleteEbsOnTerminationDefault, + ForceNew: !computed, + Computed: computed, + }, + + "device_name": &schema.Schema{ + Type: schema.TypeString, + Required: !computed, + ForceNew: !computed, + Computed: computed, + }, + + "encrypted": &schema.Schema{ + Type: schema.TypeBool, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + }, + + "iops": &schema.Schema{ + Type: schema.TypeInt, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + }, + + "snapshot_id": &schema.Schema{ + Type: schema.TypeString, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + }, + + "volume_size": &schema.Schema{ + Type: schema.TypeInt, + Optional: !computed, + Computed: true, + ForceNew: !computed, + }, + + "volume_type": &schema.Schema{ + Type: schema.TypeString, + Optional: !computed, + Computed: computed, + ForceNew: !computed, + Default: volumeTypeDefault, + }, + }, + }, + Set: func(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + buf.WriteString(fmt.Sprintf("%s-", m["device_name"].(string))) + buf.WriteString(fmt.Sprintf("%s-", m["snapshot_id"].(string))) + return hashcode.String(buf.String()) + }, + }, + + "ephemeral_block_device": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Computed: true, + ForceNew: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "device_name": &schema.Schema{ + Type: schema.TypeString, + Required: !computed, + Computed: computed, + }, + + "virtual_name": &schema.Schema{ + Type: schema.TypeString, + Required: !computed, + Computed: computed, + }, + }, + }, + Set: func(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + buf.WriteString(fmt.Sprintf("%s-", m["device_name"].(string))) + buf.WriteString(fmt.Sprintf("%s-", m["virtual_name"].(string))) + return hashcode.String(buf.String()) + }, + }, + + "tags": tagsSchema(), + + // Not a public attribute; used to let the aws_ami_copy and aws_ami_from_instance + // resources record that they implicitly created new EBS snapshots that we should + // now manage. Not set by aws_ami, since the snapshots used there are presumed to + // be independently managed. + "manage_ebs_snapshots": &schema.Schema{ + Type: schema.TypeBool, + Computed: true, + ForceNew: true, + }, + } +} diff --git a/builtin/providers/aws/resource_aws_ami_copy.go b/builtin/providers/aws/resource_aws_ami_copy.go new file mode 100644 index 000000000000..521a1b83a34f --- /dev/null +++ b/builtin/providers/aws/resource_aws_ami_copy.go @@ -0,0 +1,70 @@ +package aws + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceAwsAmiCopy() *schema.Resource { + // Inherit all of the common AMI attributes from aws_ami, since we're + // implicitly creating an aws_ami resource. + resourceSchema := resourceAwsAmiCommonSchema(true) + + // Additional attributes unique to the copy operation. + resourceSchema["source_ami_id"] = &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + } + resourceSchema["source_ami_region"] = &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + } + + return &schema.Resource{ + Create: resourceAwsAmiCopyCreate, + + Schema: resourceSchema, + + // The remaining operations are shared with the generic aws_ami resource, + // since the aws_ami_copy resource only differs in how it's created. + Read: resourceAwsAmiRead, + Update: resourceAwsAmiUpdate, + Delete: resourceAwsAmiDelete, + } +} + +func resourceAwsAmiCopyCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).ec2conn + + req := &ec2.CopyImageInput{ + Name: aws.String(d.Get("name").(string)), + Description: aws.String(d.Get("description").(string)), + SourceImageId: aws.String(d.Get("source_ami_id").(string)), + SourceRegion: aws.String(d.Get("source_ami_region").(string)), + } + + res, err := client.CopyImage(req) + if err != nil { + return err + } + + id := *res.ImageId + d.SetId(id) + d.Partial(true) // make sure we record the id even if the rest of this gets interrupted + d.Set("id", id) + d.Set("manage_ebs_snapshots", true) + d.SetPartial("id") + d.SetPartial("manage_ebs_snapshots") + d.Partial(false) + + _, err = resourceAwsAmiWaitForAvailable(id, client) + if err != nil { + return err + } + + return resourceAwsAmiUpdate(d, meta) +} diff --git a/builtin/providers/aws/resource_aws_ami_copy_test.go b/builtin/providers/aws/resource_aws_ami_copy_test.go new file mode 100644 index 000000000000..e844853ab6b8 --- /dev/null +++ b/builtin/providers/aws/resource_aws_ami_copy_test.go @@ -0,0 +1,195 @@ +package aws + +import ( + "errors" + "fmt" + "strings" + "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 TestAccAWSAMICopy(t *testing.T) { + var amiId string + snapshots := []string{} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAWSAMICopyConfig, + Check: func(state *terraform.State) error { + rs, ok := state.RootModule().Resources["aws_ami_copy.test"] + if !ok { + return fmt.Errorf("AMI resource not found") + } + + amiId = rs.Primary.ID + + if amiId == "" { + return fmt.Errorf("AMI id is not set") + } + + conn := testAccProvider.Meta().(*AWSClient).ec2conn + req := &ec2.DescribeImagesInput{ + ImageIds: []*string{aws.String(amiId)}, + } + describe, err := conn.DescribeImages(req) + if err != nil { + return err + } + + if len(describe.Images) != 1 || + *describe.Images[0].ImageId != rs.Primary.ID { + return fmt.Errorf("AMI not found") + } + + image := describe.Images[0] + if expected := "available"; *image.State != expected { + return fmt.Errorf("invalid image state; expected %v, got %v", expected, image.State) + } + if expected := "machine"; *image.ImageType != expected { + return fmt.Errorf("wrong image type; expected %v, got %v", expected, image.ImageType) + } + if expected := "terraform-acc-ami-copy"; *image.Name != expected { + return fmt.Errorf("wrong name; expected %v, got %v", expected, image.Name) + } + + for _, bdm := range image.BlockDeviceMappings { + if bdm.Ebs != nil && bdm.Ebs.SnapshotId != nil { + snapshots = append(snapshots, *bdm.Ebs.SnapshotId) + } + } + + if expected := 1; len(snapshots) != expected { + return fmt.Errorf("wrong number of snapshots; expected %v, got %v", expected, len(snapshots)) + } + + return nil + }, + }, + }, + CheckDestroy: func(state *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).ec2conn + diReq := &ec2.DescribeImagesInput{ + ImageIds: []*string{aws.String(amiId)}, + } + diRes, err := conn.DescribeImages(diReq) + if err != nil { + return err + } + + if len(diRes.Images) > 0 { + state := diRes.Images[0].State + return fmt.Errorf("AMI %v remains in state %v", amiId, state) + } + + stillExist := make([]string, 0, len(snapshots)) + checkErrors := make(map[string]error) + for _, snapshotId := range snapshots { + dsReq := &ec2.DescribeSnapshotsInput{ + SnapshotIds: []*string{aws.String(snapshotId)}, + } + _, err := conn.DescribeSnapshots(dsReq) + if err == nil { + stillExist = append(stillExist, snapshotId) + continue + } + + awsErr, ok := err.(awserr.Error) + if !ok { + checkErrors[snapshotId] = err + continue + } + + if awsErr.Code() != "InvalidSnapshot.NotFound" { + checkErrors[snapshotId] = err + continue + } + } + + if len(stillExist) > 0 || len(checkErrors) > 0 { + errParts := []string{ + "Expected all snapshots to be gone, but:", + } + for _, snapshotId := range stillExist { + errParts = append( + errParts, + fmt.Sprintf("- %v still exists", snapshotId), + ) + } + for snapshotId, err := range checkErrors { + errParts = append( + errParts, + fmt.Sprintf("- checking %v gave error: %v", snapshotId, err), + ) + } + return errors.New(strings.Join(errParts, "\n")) + } + + return nil + }, + }) +} + +var testAccAWSAMICopyConfig = ` +provider "aws" { + region = "us-east-1" +} + +// An AMI can't be directly copied from one account to another, and +// we can't rely on any particular AMI being available since anyone +// can run this test in whatever account they like. +// Therefore we jump through some hoops here: +// - Spin up an EC2 instance based on a public AMI +// - Create an AMI by snapshotting that EC2 instance, using +// aws_ami_from_instance . +// - Copy the new AMI using aws_ami_copy . +// +// Thus this test can only succeed if the aws_ami_from_instance resource +// is working. If it's misbehaving it will likely cause this test to fail too. + +// Since we're booting a t2.micro HVM instance we need a VPC for it to boot +// up into. + +resource "aws_vpc" "foo" { + cidr_block = "10.1.0.0/16" +} + +resource "aws_subnet" "foo" { + cidr_block = "10.1.1.0/24" + vpc_id = "${aws_vpc.foo.id}" +} + +resource "aws_instance" "test" { + // This AMI has one block device mapping, so we expect to have + // one snapshot in our created AMI. + // This is an Amazon Linux HVM AMI. A public HVM AMI is required + // because paravirtual images cannot be copied between accounts. + ami = "ami-8fff43e4" + instance_type = "t2.micro" + tags { + Name = "terraform-acc-ami-copy-victim" + } + + subnet_id = "${aws_subnet.foo.id}" +} + +resource "aws_ami_from_instance" "test" { + name = "terraform-acc-ami-copy-victim" + description = "Testing Terraform aws_ami_from_instance resource" + source_instance_id = "${aws_instance.test.id}" +} + +resource "aws_ami_copy" "test" { + name = "terraform-acc-ami-copy" + description = "Testing Terraform aws_ami_copy resource" + source_ami_id = "${aws_ami_from_instance.test.id}" + source_ami_region = "us-east-1" +} +` diff --git a/builtin/providers/aws/resource_aws_ami_from_instance.go b/builtin/providers/aws/resource_aws_ami_from_instance.go new file mode 100644 index 000000000000..cc272d3c15d0 --- /dev/null +++ b/builtin/providers/aws/resource_aws_ami_from_instance.go @@ -0,0 +1,70 @@ +package aws + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceAwsAmiFromInstance() *schema.Resource { + // Inherit all of the common AMI attributes from aws_ami, since we're + // implicitly creating an aws_ami resource. + resourceSchema := resourceAwsAmiCommonSchema(true) + + // Additional attributes unique to the copy operation. + resourceSchema["source_instance_id"] = &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + } + resourceSchema["snapshot_without_reboot"] = &schema.Schema{ + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + } + + return &schema.Resource{ + Create: resourceAwsAmiFromInstanceCreate, + + Schema: resourceSchema, + + // The remaining operations are shared with the generic aws_ami resource, + // since the aws_ami_copy resource only differs in how it's created. + Read: resourceAwsAmiRead, + Update: resourceAwsAmiUpdate, + Delete: resourceAwsAmiDelete, + } +} + +func resourceAwsAmiFromInstanceCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*AWSClient).ec2conn + + req := &ec2.CreateImageInput{ + Name: aws.String(d.Get("name").(string)), + Description: aws.String(d.Get("description").(string)), + InstanceId: aws.String(d.Get("source_instance_id").(string)), + NoReboot: aws.Bool(d.Get("snapshot_without_reboot").(bool)), + } + + res, err := client.CreateImage(req) + if err != nil { + return err + } + + id := *res.ImageId + d.SetId(id) + d.Partial(true) // make sure we record the id even if the rest of this gets interrupted + d.Set("id", id) + d.Set("manage_ebs_snapshots", true) + d.SetPartial("id") + d.SetPartial("manage_ebs_snapshots") + d.Partial(false) + + _, err = resourceAwsAmiWaitForAvailable(id, client) + if err != nil { + return err + } + + return resourceAwsAmiUpdate(d, meta) +} diff --git a/builtin/providers/aws/resource_aws_ami_from_instance_test.go b/builtin/providers/aws/resource_aws_ami_from_instance_test.go new file mode 100644 index 000000000000..a81dc1f8cfc9 --- /dev/null +++ b/builtin/providers/aws/resource_aws_ami_from_instance_test.go @@ -0,0 +1,157 @@ +package aws + +import ( + "errors" + "fmt" + "strings" + "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 TestAccAWSAMIFromInstance(t *testing.T) { + var amiId string + snapshots := []string{} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAWSAMIFromInstanceConfig, + Check: func(state *terraform.State) error { + rs, ok := state.RootModule().Resources["aws_ami_from_instance.test"] + if !ok { + return fmt.Errorf("AMI resource not found") + } + + amiId = rs.Primary.ID + + if amiId == "" { + return fmt.Errorf("AMI id is not set") + } + + conn := testAccProvider.Meta().(*AWSClient).ec2conn + req := &ec2.DescribeImagesInput{ + ImageIds: []*string{aws.String(amiId)}, + } + describe, err := conn.DescribeImages(req) + if err != nil { + return err + } + + if len(describe.Images) != 1 || + *describe.Images[0].ImageId != rs.Primary.ID { + return fmt.Errorf("AMI not found") + } + + image := describe.Images[0] + if expected := "available"; *image.State != expected { + return fmt.Errorf("invalid image state; expected %v, got %v", expected, image.State) + } + if expected := "machine"; *image.ImageType != expected { + return fmt.Errorf("wrong image type; expected %v, got %v", expected, image.ImageType) + } + if expected := "terraform-acc-ami-from-instance"; *image.Name != expected { + return fmt.Errorf("wrong name; expected %v, got %v", expected, image.Name) + } + + for _, bdm := range image.BlockDeviceMappings { + if bdm.Ebs != nil && bdm.Ebs.SnapshotId != nil { + snapshots = append(snapshots, *bdm.Ebs.SnapshotId) + } + } + + if expected := 1; len(snapshots) != expected { + return fmt.Errorf("wrong number of snapshots; expected %v, got %v", expected, len(snapshots)) + } + + return nil + }, + }, + }, + CheckDestroy: func(state *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).ec2conn + diReq := &ec2.DescribeImagesInput{ + ImageIds: []*string{aws.String(amiId)}, + } + diRes, err := conn.DescribeImages(diReq) + if err != nil { + return err + } + + if len(diRes.Images) > 0 { + state := diRes.Images[0].State + return fmt.Errorf("AMI %v remains in state %v", amiId, state) + } + + stillExist := make([]string, 0, len(snapshots)) + checkErrors := make(map[string]error) + for _, snapshotId := range snapshots { + dsReq := &ec2.DescribeSnapshotsInput{ + SnapshotIds: []*string{aws.String(snapshotId)}, + } + _, err := conn.DescribeSnapshots(dsReq) + if err == nil { + stillExist = append(stillExist, snapshotId) + continue + } + + awsErr, ok := err.(awserr.Error) + if !ok { + checkErrors[snapshotId] = err + continue + } + + if awsErr.Code() != "InvalidSnapshot.NotFound" { + checkErrors[snapshotId] = err + continue + } + } + + if len(stillExist) > 0 || len(checkErrors) > 0 { + errParts := []string{ + "Expected all snapshots to be gone, but:", + } + for _, snapshotId := range stillExist { + errParts = append( + errParts, + fmt.Sprintf("- %v still exists", snapshotId), + ) + } + for snapshotId, err := range checkErrors { + errParts = append( + errParts, + fmt.Sprintf("- checking %v gave error: %v", snapshotId, err), + ) + } + return errors.New(strings.Join(errParts, "\n")) + } + + return nil + }, + }) +} + +var testAccAWSAMIFromInstanceConfig = ` +provider "aws" { + region = "us-east-1" +} + +resource "aws_instance" "test" { + // This AMI has one block device mapping, so we expect to have + // one snapshot in our created AMI. + ami = "ami-408c7f28" + instance_type = "t1.micro" +} + +resource "aws_ami_from_instance" "test" { + name = "terraform-acc-ami-from-instance" + description = "Testing Terraform aws_ami_from_instance resource" + source_instance_id = "${aws_instance.test.id}" +} +` diff --git a/builtin/providers/aws/resource_aws_ami_test.go b/builtin/providers/aws/resource_aws_ami_test.go new file mode 100644 index 000000000000..60701860827f --- /dev/null +++ b/builtin/providers/aws/resource_aws_ami_test.go @@ -0,0 +1,8 @@ +package aws + +// FIXME: The aws_ami resource doesn't currently have any acceptance tests, +// since creating an AMI requires having an EBS snapshot and we don't yet +// have a resource type for creating those. +// Once there is an aws_ebs_snapshot resource we can use it to implement +// a reasonable acceptance test for aws_ami. Until then it's necessary to +// test manually using a pre-existing EBS snapshot. diff --git a/website/source/docs/providers/aws/r/ami.html.markdown b/website/source/docs/providers/aws/r/ami.html.markdown new file mode 100644 index 000000000000..d9d6cd5d6805 --- /dev/null +++ b/website/source/docs/providers/aws/r/ami.html.markdown @@ -0,0 +1,92 @@ +--- +layout: "aws" +page_title: "AWS: aws_ami" +sidebar_current: "docs-aws-resource-ami" +description: |- + Creates and manages a custom Amazon Machine Image (AMI). +--- + +# aws\_ami + +The AMI resource allows the creation and management of a completely-custom +*Amazon Machine Image* (AMI). + +If you just want to duplicate an existing AMI, possibly copying it to another +region, it's better to use `aws_ami_copy` instead. + +## Example Usage + +``` +# Create an AMI that will start a machine whose root device is backed by +# an EBS volume populated from a snapshot. It is assumed that such a snapshot +# already exists with the id "snap-xxxxxxxx". +resource "aws_ami" "example" { + name = "terraform-example" + virtualization_type = "hvm" + root_device_name = "/dev/xvda" + + ebs_block_device { + device_name = "/dev/xvda" + snapshot_id = "snap-xxxxxxxx" + volume_size = 8 + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) A region-unique name for the AMI. +* `description` - (Optional) A longer, human-readable description for the AMI. +* `virtualization_type` - (Optional) Keyword to choose what virtualization mode created instances + will use. Can be either "paravirtual" (the default) or "hvm". The choice of virtualization type + changes the set of further arguments that are required, as described below. +* `architecture` - (Optional) Machine architecture for created instances. Defaults to "x86_64". +* `ebs_block_device` - (Optional) Nested block describing an EBS block device that should be + attached to created instances. The structure of this block is described below. +* `ephemeral_block_device` - (Optional) Nested block describing an ephemeral block device that + should be attached to created instances. The structure of this block is described below. + +When `virtualization_type` is "paravirtual" the following additional arguments apply: + +* `image_location` - (Required) Path to an S3 object containing an image manifest, e.g. created + by the `ec2-upload-bundle` command in the EC2 command line tools. +* `kernel_id` - (Required) The id of the kernel image (AKI) that will be used as the paravirtual + kernel in created instances. +* `ramdisk_id` - (Optional) The id of an initrd image (ARI) that will be used when booting the + created instances. + +When `virtualization_type` is "hvm" the following additional arguments apply: + +* `sriov_net_support` - (Optional) When set to "simple" (the default), enables enhanced networking + for created instances. No other value is supported at this time. + +Nested `ebs_block_device` blocks have the following structure: + +* `device_name` - (Required) The path at which the device is exposed to created instances. +* `delete_on_termination` - (Optional) Boolean controlling whether the EBS volumes created to + support each created instance will be deleted once that instance is terminated. +* `encrypted` - (Optional) Boolean controlling whether the created EBS volumes will be encrypted. +* `iops` - (Required only when `volume_type` is "io1") Number of I/O operations per second the + created volumes will support. +* `snapshot_id` - (Optional) The id of an EBS snapshot that will be used to initialize the created + EBS volumes. If set, the `volume_size` attribute must be at least as large as the referenced + snapshot. +* `volume_size` - (Required unless `snapshot_id` is set) The size of created volumes in GiB. + If `snapshot_id` is set and `volume_size` is omitted then the volume will have the same size + as the selected snapshot. +* `volume_type` - (Optional) The type of EBS volume to create. Can be one of "standard" (the + default), "io1" or "gp2". + +Nested `ephemeral_block_device` blocks have the following structure: + +* `device_name` - (Required) The path at which the device is exposed to created instances. +* `virtual_name` - (Required) A name for the ephemeral device, of the form "ephemeralN" where + *N* is a volume number starting from zero. + +## Attributes Reference + +The following attributes are exported: + +* `id` - The ID of the created AMI. diff --git a/website/source/docs/providers/aws/r/ami_copy.html.markdown b/website/source/docs/providers/aws/r/ami_copy.html.markdown new file mode 100644 index 000000000000..9e1ac7ada00c --- /dev/null +++ b/website/source/docs/providers/aws/r/ami_copy.html.markdown @@ -0,0 +1,51 @@ +--- +layout: "aws" +page_title: "AWS: aws_ami_copy" +sidebar_current: "docs-aws-resource-ami-copy" +description: |- + Duplicates an existing Amazon Machine Image (AMI) +--- + +# aws\_ami\_copy + +The "AMI copy" resource allows duplication of an Amazon Machine Image (AMI), +including cross-region copies. + +If the source AMI has associated EBS snapshots, those will also be duplicated +along with the AMI. + +This is useful for taking a single AMI provisioned in one region and making +it available in another for a multi-region deployment. + +Copying an AMI can take several minutes. The creation of this resource will +block until the new AMI is available for use on new instances. + +## Example Usage + +``` +resource "aws_ami_copy" "example" { + name = "terraform-example" + source_ami_id = "ami-xxxxxxxx" + source_ami_region = "us-west-1" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) A region-unique name for the AMI. +* `source_ami_id` - (Required) The id of the AMI to copy. This id must be valid in the region + given by `source_ami_region`. +* `source_region` - (Required) The region from which the AMI will be copied. This may be the + same as the AWS provider region in order to create a copy within the same region. + +## Attributes Reference + +The following attributes are exported: + +* `id` - The ID of the created AMI. + +This resource also exports a full set of attributes corresponding to the arguments of the +`aws_ami` resource, allowing the properties of the created AMI to be used elsewhere in the +configuration. diff --git a/website/source/docs/providers/aws/r/ami_from_instance.html.markdown b/website/source/docs/providers/aws/r/ami_from_instance.html.markdown new file mode 100644 index 000000000000..a9e5fd515206 --- /dev/null +++ b/website/source/docs/providers/aws/r/ami_from_instance.html.markdown @@ -0,0 +1,57 @@ +--- +layout: "aws" +page_title: "AWS: aws_ami_from_instance" +sidebar_current: "docs-aws-resource-ami-from-instance" +description: |- + Creates an Amazon Machine Image (AMI) from an EBS-backed EC2 instance +--- + +# aws\_ami\_from\_instance + +The "AMI from instance" resource allows the creation of an Amazon Machine +Image (AMI) modelled after an existing EBS-backed EC2 instance. + +The created AMI will refer to implicitly-created snapshots of the instance's +EBS volumes and mimick its assigned block device configuration at the time +the resource is created. + +This resource is best applied to an instance that is stopped when this instance +is created, so that the contents of the created image are predictable. When +applied to an instance that is running, *the instance will be stopped before taking +the snapshots and then started back up again*, resulting in a period of +downtime. + +Note that the source instance is inspected only at the initial creation of this +resource. Ongoing updates to the referenced instance will not be propagated into +the generated AMI. Users may taint or otherwise recreate the resource in order +to produce a fresh snapshot. + +## Example Usage + +``` +resource "aws_ami_from_instance" "example" { + name = "terraform-example" + source_instance_id = "i-xxxxxxxx" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) A region-unique name for the AMI. +* `source_instance_id` - (Required) The id of the instance to use as the basis of the AMI. +* `snapshot_without_reboot` - (Optional) Boolean that overrides the behavior of stopping + the instance before snapshotting. This is risky since it may cause a snapshot of an + inconsistent filesystem state, but can be used to avoid downtime if the user otherwise + guarantees that no filesystem writes will be underway at the time of snapshot. + +## Attributes Reference + +The following attributes are exported: + +* `id` - The ID of the created AMI. + +This resource also exports a full set of attributes corresponding to the arguments of the +`aws_ami` resource, allowing the properties of the created AMI to be used elsewhere in the +configuration. diff --git a/website/source/layouts/aws.erb b/website/source/layouts/aws.erb index 2bbff22f4b9b..22801a5075af 100644 --- a/website/source/layouts/aws.erb +++ b/website/source/layouts/aws.erb @@ -39,6 +39,18 @@ EC2 Resources