diff --git a/builtin/providers/digitalocean/config.go b/builtin/providers/digitalocean/config.go index 498bf790b..a0a9115ae 100644 --- a/builtin/providers/digitalocean/config.go +++ b/builtin/providers/digitalocean/config.go @@ -2,8 +2,10 @@ package digitalocean import ( "log" + "time" "github.com/digitalocean/godo" + "github.com/hashicorp/terraform/helper/resource" "golang.org/x/oauth2" ) @@ -23,3 +25,39 @@ func (c *Config) Client() (*godo.Client, error) { return client, nil } + +// waitForAction waits for the action to finish using the resource.StateChangeConf. +func waitForAction(client *godo.Client, action *godo.Action) error { + var ( + pending = "in-progress" + target = "completed" + refreshfn = func() (result interface{}, state string, err error) { + a, _, err := client.Actions.Get(action.ID) + if err != nil { + return nil, "", err + } + if a.Status == "errored" { + return a, "errored", nil + } + if a.CompletedAt != nil { + return a, target, nil + } + return a, pending, nil + } + ) + _, err := (&resource.StateChangeConf{ + Pending: []string{pending}, + Refresh: refreshfn, + Target: []string{target}, + + Delay: 10 * time.Second, + Timeout: 60 * time.Minute, + MinTimeout: 3 * time.Second, + + // This is a hack around DO API strangeness. + // https://github.com/hashicorp/terraform/issues/481 + // + NotFoundChecks: 60, + }).WaitForState() + return err +} diff --git a/builtin/providers/digitalocean/import_digitalocean_volume_test.go b/builtin/providers/digitalocean/import_digitalocean_volume_test.go new file mode 100644 index 000000000..599e75806 --- /dev/null +++ b/builtin/providers/digitalocean/import_digitalocean_volume_test.go @@ -0,0 +1,32 @@ +package digitalocean + +import ( + "testing" + + "fmt" + + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" +) + +func TestAccDigitalOceanVolume_importBasic(t *testing.T) { + resourceName := "digitalocean_volume.foobar" + volumeName := fmt.Sprintf("volume-%s", acctest.RandString(10)) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckDigitalOceanVolumeDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: fmt.Sprintf(testAccCheckDigitalOceanVolumeConfig_basic, volumeName), + }, + + resource.TestStep{ + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} diff --git a/builtin/providers/digitalocean/provider.go b/builtin/providers/digitalocean/provider.go index 3e8771212..48b9d144f 100644 --- a/builtin/providers/digitalocean/provider.go +++ b/builtin/providers/digitalocean/provider.go @@ -24,6 +24,7 @@ func Provider() terraform.ResourceProvider { "digitalocean_record": resourceDigitalOceanRecord(), "digitalocean_ssh_key": resourceDigitalOceanSSHKey(), "digitalocean_tag": resourceDigitalOceanTag(), + "digitalocean_volume": resourceDigitalOceanVolume(), }, ConfigureFunc: providerConfigure, diff --git a/builtin/providers/digitalocean/resource_digitalocean_droplet.go b/builtin/providers/digitalocean/resource_digitalocean_droplet.go index ae22b9131..a44f3eb3a 100644 --- a/builtin/providers/digitalocean/resource_digitalocean_droplet.go +++ b/builtin/providers/digitalocean/resource_digitalocean_droplet.go @@ -115,6 +115,12 @@ func resourceDigitalOceanDroplet() *schema.Resource { Optional: true, ForceNew: true, }, + + "volume_ids": &schema.Schema{ + Type: schema.TypeList, + Elem: &schema.Schema{Type: schema.TypeString}, + Optional: true, + }, }, } } @@ -148,6 +154,14 @@ func resourceDigitalOceanDropletCreate(d *schema.ResourceData, meta interface{}) opts.UserData = attr.(string) } + if attr, ok := d.GetOk("volume_ids"); ok { + for _, id := range attr.([]interface{}) { + opts.Volumes = append(opts.Volumes, godo.DropletCreateVolume{ + ID: id.(string), + }) + } + } + // Get configured ssh_keys sshKeys := d.Get("ssh_keys.#").(int) if sshKeys > 0 { @@ -229,6 +243,14 @@ func resourceDigitalOceanDropletRead(d *schema.ResourceData, meta interface{}) e d.Set("status", droplet.Status) d.Set("locked", strconv.FormatBool(droplet.Locked)) + if len(droplet.VolumeIDs) > 0 { + vlms := make([]interface{}, 0, len(droplet.VolumeIDs)) + for _, vid := range droplet.VolumeIDs { + vlms = append(vlms, vid) + } + d.Set("volume_ids", vlms) + } + if publicIPv6 := findIPv6AddrByType(droplet, "public"); publicIPv6 != "" { d.Set("ipv6", true) d.Set("ipv6_address", publicIPv6) @@ -400,6 +422,49 @@ func resourceDigitalOceanDropletUpdate(d *schema.ResourceData, meta interface{}) } } + if d.HasChange("volume_ids") { + oldIDs, newIDs := d.GetChange("volume_ids") + newSet := func(ids []interface{}) map[string]struct{} { + out := make(map[string]struct{}, len(ids)) + for _, id := range ids { + out[id.(string)] = struct{}{} + } + return out + } + // leftDiff returns all elements in Left that are not in Right + leftDiff := func(left, right map[string]struct{}) map[string]struct{} { + out := make(map[string]struct{}) + for l := range left { + if _, ok := right[l]; !ok { + out[l] = struct{}{} + } + } + return out + } + oldIDSet := newSet(oldIDs.([]interface{})) + newIDSet := newSet(newIDs.([]interface{})) + for volumeID := range leftDiff(newIDSet, oldIDSet) { + action, _, err := client.StorageActions.Attach(volumeID, id) + if err != nil { + return fmt.Errorf("Error attaching volume %q to droplet (%s): %s", volumeID, d.Id(), err) + } + // can't fire >1 action at a time, so waiting for each is OK + if err := waitForAction(client, action); err != nil { + return fmt.Errorf("Error waiting for volume %q to attach to droplet (%s): %s", volumeID, d.Id(), err) + } + } + for volumeID := range leftDiff(oldIDSet, newIDSet) { + action, _, err := client.StorageActions.Detach(volumeID) + if err != nil { + return fmt.Errorf("Error detaching volume %q from droplet (%s): %s", volumeID, d.Id(), err) + } + // can't fire >1 action at a time, so waiting for each is OK + if err := waitForAction(client, action); err != nil { + return fmt.Errorf("Error waiting for volume %q to detach from droplet (%s): %s", volumeID, d.Id(), err) + } + } + } + return resourceDigitalOceanDropletRead(d, meta) } diff --git a/builtin/providers/digitalocean/resource_digitalocean_volume.go b/builtin/providers/digitalocean/resource_digitalocean_volume.go new file mode 100644 index 000000000..2fe05551e --- /dev/null +++ b/builtin/providers/digitalocean/resource_digitalocean_volume.go @@ -0,0 +1,146 @@ +package digitalocean + +import ( + "fmt" + "log" + + "github.com/digitalocean/godo" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceDigitalOceanVolume() *schema.Resource { + return &schema.Resource{ + Create: resourceDigitalOceanVolumeCreate, + Read: resourceDigitalOceanVolumeRead, + Delete: resourceDigitalOceanVolumeDelete, + Importer: &schema.ResourceImporter{ + State: resourceDigitalOceanVolumeImport, + }, + + Schema: map[string]*schema.Schema{ + "region": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "id": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + + "name": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "droplet_ids": &schema.Schema{ + Type: schema.TypeSet, + Elem: &schema.Schema{Type: schema.TypeInt}, + Computed: true, + }, + + "size": &schema.Schema{ + Type: schema.TypeInt, + Required: true, + ForceNew: true, // Update-ability Coming Soon ™ + }, + + "description": &schema.Schema{ + Type: schema.TypeString, + Optional: true, + ForceNew: true, // Update-ability Coming Soon ™ + }, + }, + } +} + +func resourceDigitalOceanVolumeCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*godo.Client) + + opts := &godo.VolumeCreateRequest{ + Region: d.Get("region").(string), + Name: d.Get("name").(string), + Description: d.Get("description").(string), + SizeGigaBytes: int64(d.Get("size").(int)), + } + + log.Printf("[DEBUG] Volume create configuration: %#v", opts) + volume, _, err := client.Storage.CreateVolume(opts) + if err != nil { + return fmt.Errorf("Error creating Volume: %s", err) + } + + d.SetId(volume.ID) + log.Printf("[INFO] Volume name: %s", volume.Name) + + return resourceDigitalOceanVolumeRead(d, meta) +} + +func resourceDigitalOceanVolumeRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*godo.Client) + + volume, resp, err := client.Storage.GetVolume(d.Id()) + if err != nil { + // If the volume is somehow already destroyed, mark as + // successfully gone + if resp.StatusCode == 404 { + d.SetId("") + return nil + } + + return fmt.Errorf("Error retrieving volume: %s", err) + } + + d.Set("id", volume.ID) + + dids := make([]interface{}, 0, len(volume.DropletIDs)) + for _, did := range volume.DropletIDs { + dids = append(dids, did) + } + d.Set("droplet_ids", schema.NewSet( + func(dropletID interface{}) int { return dropletID.(int) }, + dids, + )) + + return nil +} + +func resourceDigitalOceanVolumeDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*godo.Client) + + log.Printf("[INFO] Deleting volume: %s", d.Id()) + _, err := client.Storage.DeleteVolume(d.Id()) + if err != nil { + return fmt.Errorf("Error deleting volume: %s", err) + } + + d.SetId("") + return nil +} + +func resourceDigitalOceanVolumeImport(rs *schema.ResourceData, v interface{}) ([]*schema.ResourceData, error) { + client := v.(*godo.Client) + volume, _, err := client.Storage.GetVolume(rs.Id()) + if err != nil { + return nil, err + } + + rs.Set("id", volume.ID) + rs.Set("name", volume.Name) + rs.Set("region", volume.Region.Slug) + rs.Set("description", volume.Description) + rs.Set("size", int(volume.SizeGigaBytes)) + + dids := make([]interface{}, 0, len(volume.DropletIDs)) + for _, did := range volume.DropletIDs { + dids = append(dids, did) + } + rs.Set("droplet_ids", schema.NewSet( + func(dropletID interface{}) int { return dropletID.(int) }, + dids, + )) + + return []*schema.ResourceData{rs}, nil +} diff --git a/builtin/providers/digitalocean/resource_digitalocean_volume_test.go b/builtin/providers/digitalocean/resource_digitalocean_volume_test.go new file mode 100644 index 000000000..f1c7aca2c --- /dev/null +++ b/builtin/providers/digitalocean/resource_digitalocean_volume_test.go @@ -0,0 +1,145 @@ +package digitalocean + +import ( + "fmt" + "testing" + + "github.com/digitalocean/godo" + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccDigitalOceanVolume_Basic(t *testing.T) { + name := fmt.Sprintf("volume-%s", acctest.RandString(10)) + + volume := godo.Volume{ + Name: name, + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckDigitalOceanVolumeDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: fmt.Sprintf(testAccCheckDigitalOceanVolumeConfig_basic, name), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanVolumeExists("digitalocean_volume.foobar", &volume), + resource.TestCheckResourceAttr( + "digitalocean_volume.foobar", "name", name), + resource.TestCheckResourceAttr( + "digitalocean_volume.foobar", "size", "100"), + resource.TestCheckResourceAttr( + "digitalocean_volume.foobar", "region", "nyc1"), + resource.TestCheckResourceAttr( + "digitalocean_volume.foobar", "description", "peace makes plenty"), + ), + }, + }, + }) +} + +const testAccCheckDigitalOceanVolumeConfig_basic = ` +resource "digitalocean_volume" "foobar" { + region = "nyc1" + name = "%s" + size = 100 + description = "peace makes plenty" +}` + +func testAccCheckDigitalOceanVolumeExists(rn string, volume *godo.Volume) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[rn] + if !ok { + return fmt.Errorf("not found: %s", rn) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no volume ID is set") + } + + client := testAccProvider.Meta().(*godo.Client) + + got, _, err := client.Storage.GetVolume(rs.Primary.ID) + if err != nil { + return err + } + if got.Name != volume.Name { + return fmt.Errorf("wrong volume found, want %q got %q", volume.Name, got.Name) + } + // get the computed volume details + *volume = *got + return nil + } +} + +func testAccCheckDigitalOceanVolumeDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*godo.Client) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "digitalocean_volume" { + continue + } + + // Try to find the volume + _, _, err := client.Storage.GetVolume(rs.Primary.ID) + + if err == nil { + return fmt.Errorf("Volume still exists") + } + } + + return nil +} + +func TestAccDigitalOceanVolume_Droplet(t *testing.T) { + var ( + volume = godo.Volume{Name: fmt.Sprintf("volume-%s", acctest.RandString(10))} + droplet godo.Droplet + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckDigitalOceanVolumeDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: fmt.Sprintf( + testAccCheckDigitalOceanVolumeConfig_droplet, + testAccValidPublicKey, volume.Name, + ), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanVolumeExists("digitalocean_volume.foobar", &volume), + testAccCheckDigitalOceanDropletExists("digitalocean_droplet.foobar", &droplet), + // the droplet should see an attached volume + resource.TestCheckResourceAttr( + "digitalocean_droplet.foobar", "volume_ids", volume.ID), + ), + }, + }, + }) +} + +const testAccCheckDigitalOceanVolumeConfig_droplet = ` +resource "digitalocean_ssh_key" "foobar" { + name = "foobar" + public_key = "%s" +} + +resource "digitalocean_volume" "foobar" { + region = "nyc1" + name = "%s" + size = 100 + description = "peace makes plenty" +} + +resource "digitalocean_droplet" "foobar" { + name = "baz" + size = "1gb" + image = "coreos-stable" + region = "nyc1" + ipv6 = true + private_networking = true + ssh_keys = ["${digitalocean_ssh_key.foobar.id}"] + volume_ids = ["${digitalocean_volume.foobar.id}"] +}` diff --git a/website/source/docs/providers/do/r/droplet.html.markdown b/website/source/docs/providers/do/r/droplet.html.markdown index f9aab4f68..8265e6803 100644 --- a/website/source/docs/providers/do/r/droplet.html.markdown +++ b/website/source/docs/providers/do/r/droplet.html.markdown @@ -3,19 +3,19 @@ layout: "digitalocean" page_title: "DigitalOcean: digitalocean_droplet" sidebar_current: "docs-do-resource-droplet" description: |- - Provides a DigitalOcean droplet resource. This can be used to create, modify, and delete droplets. Droplets also support provisioning. + Provides a DigitalOcean Droplet resource. This can be used to create, modify, and delete Droplets. Droplets also support provisioning. --- # digitalocean\_droplet -Provides a DigitalOcean droplet resource. This can be used to create, -modify, and delete droplets. Droplets also support +Provides a DigitalOcean Droplet resource. This can be used to create, +modify, and delete Droplets. Droplets also support [provisioning](/docs/provisioners/index.html). ## Example Usage ``` -# Create a new Web droplet in the nyc2 region +# Create a new Web Droplet in the nyc2 region resource "digitalocean_droplet" "web" { image = "ubuntu-14-04-x64" name = "web-1" @@ -28,12 +28,12 @@ resource "digitalocean_droplet" "web" { The following arguments are supported: -* `image` - (Required) The droplet image ID or slug. -* `name` - (Required) The droplet name +* `image` - (Required) The Droplet image ID or slug. +* `name` - (Required) The Droplet name * `region` - (Required) The region to start in -* `size` - (Required) The instance size to start +* `size` - (Required) The instance size to start --> **Note:** When resizing a droplet, only a bigger droplet size can be chosen. +-> **Note:** When resizing a Droplet, only a bigger Droplet size can be chosen. * `backups` - (Optional) Boolean controlling if backups are made. Defaults to false. @@ -49,15 +49,16 @@ The following arguments are supported: * `user_data` (Optional) - A string of the desired User Data for the Droplet. User Data is currently only available in regions with metadata listed in their features. +* `volume_ids` (Optional) - A list of the IDs of each [block storage volume](/docs/providers/do/r/volume.html) to be attached to the Droplet. ## Attributes Reference The following attributes are exported: -* `id` - The ID of the droplet -* `name`- The name of the droplet -* `region` - The region of the droplet -* `image` - The image of the droplet +* `id` - The ID of the Droplet +* `name`- The name of the Droplet +* `region` - The region of the Droplet +* `image` - The image of the Droplet * `ipv6` - Is IPv6 enabled * `ipv6_address` - The IPv6 address * `ipv6_address_private` - The private networking IPv6 address @@ -68,3 +69,4 @@ The following attributes are exported: * `size` - The instance size * `status` - The status of the droplet * `tags` - The tags associated with the droplet +* `volume_ids` - A list of the attached block storage volumes diff --git a/website/source/docs/providers/do/r/volume.markdown b/website/source/docs/providers/do/r/volume.markdown new file mode 100644 index 000000000..d14416eb6 --- /dev/null +++ b/website/source/docs/providers/do/r/volume.markdown @@ -0,0 +1,45 @@ +--- +layout: "digitalocean" +page_title: "DigitalOcean: digitalocean_volume" +sidebar_current: "docs-do-resource-volume" +description: |- + Provides a DigitalOcean volume resource. +--- + +# digitalocean\_volume + +Provides a DigitalOcean Block Storage volume which can be attached to a Droplet in order to provide expanded storage. + +## Example Usage + +``` +resource "digitalocean_volume" "foobar" { + region = "nyc1" + name = "baz" + size = 100 + description = "an example volume" +} + +resource "digitalocean_droplet" "foobar" { + name = "baz" + size = "1gb" + image = "coreos-stable" + region = "nyc1" + volume_ids = ["${digitalocean_volume.foobar.id}"] +} +``` + +## Argument Reference + +The following arguments are supported: + +* `region` - (Required) The region that the block storage volume will be created in. +* `name` - (Required) A name for the block storage volume. Must be lowercase and be composed only of numbers, letters and "-", up to a limit of 64 characters. +* `size` - (Required) The size of the block storage volume in GiB. +* `description` - (Optional) A free-form text field up to a limit of 1024 bytes to describe a block storage volume. + +## Attributes Reference + +The following attributes are exported: + +* `id` - The unique identifier for the block storage volume. \ No newline at end of file diff --git a/website/source/layouts/digitalocean.erb b/website/source/layouts/digitalocean.erb index f3f4306df..a7855e821 100644 --- a/website/source/layouts/digitalocean.erb +++ b/website/source/layouts/digitalocean.erb @@ -31,6 +31,9 @@ > digitalocean_ssh_key + + > + digitalocean_volume