provider/digitalocean: adds a volume resource (#7560)

* provider/digitalocean: add support for volumes

* provider/digitalocean: add documentation for volume resource
This commit is contained in:
Antoine Grondin 2016-07-13 08:36:37 -06:00 committed by Paul Stack
parent 9018e073fb
commit 3d6fe76b52
9 changed files with 489 additions and 12 deletions

View File

@ -2,8 +2,10 @@ package digitalocean
import ( import (
"log" "log"
"time"
"github.com/digitalocean/godo" "github.com/digitalocean/godo"
"github.com/hashicorp/terraform/helper/resource"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
@ -23,3 +25,39 @@ func (c *Config) Client() (*godo.Client, error) {
return client, nil 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
}

View File

@ -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,
},
},
})
}

View File

@ -24,6 +24,7 @@ func Provider() terraform.ResourceProvider {
"digitalocean_record": resourceDigitalOceanRecord(), "digitalocean_record": resourceDigitalOceanRecord(),
"digitalocean_ssh_key": resourceDigitalOceanSSHKey(), "digitalocean_ssh_key": resourceDigitalOceanSSHKey(),
"digitalocean_tag": resourceDigitalOceanTag(), "digitalocean_tag": resourceDigitalOceanTag(),
"digitalocean_volume": resourceDigitalOceanVolume(),
}, },
ConfigureFunc: providerConfigure, ConfigureFunc: providerConfigure,

View File

@ -115,6 +115,12 @@ func resourceDigitalOceanDroplet() *schema.Resource {
Optional: true, Optional: true,
ForceNew: 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) 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 // Get configured ssh_keys
sshKeys := d.Get("ssh_keys.#").(int) sshKeys := d.Get("ssh_keys.#").(int)
if sshKeys > 0 { if sshKeys > 0 {
@ -229,6 +243,14 @@ func resourceDigitalOceanDropletRead(d *schema.ResourceData, meta interface{}) e
d.Set("status", droplet.Status) d.Set("status", droplet.Status)
d.Set("locked", strconv.FormatBool(droplet.Locked)) 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 != "" { if publicIPv6 := findIPv6AddrByType(droplet, "public"); publicIPv6 != "" {
d.Set("ipv6", true) d.Set("ipv6", true)
d.Set("ipv6_address", publicIPv6) 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) return resourceDigitalOceanDropletRead(d, meta)
} }

View File

@ -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
}

View File

@ -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}"]
}`

View File

@ -3,19 +3,19 @@ layout: "digitalocean"
page_title: "DigitalOcean: digitalocean_droplet" page_title: "DigitalOcean: digitalocean_droplet"
sidebar_current: "docs-do-resource-droplet" sidebar_current: "docs-do-resource-droplet"
description: |- 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 # digitalocean\_droplet
Provides a DigitalOcean droplet resource. This can be used to create, Provides a DigitalOcean Droplet resource. This can be used to create,
modify, and delete droplets. Droplets also support modify, and delete Droplets. Droplets also support
[provisioning](/docs/provisioners/index.html). [provisioning](/docs/provisioners/index.html).
## Example Usage ## Example Usage
``` ```
# Create a new Web droplet in the nyc2 region # Create a new Web Droplet in the nyc2 region
resource "digitalocean_droplet" "web" { resource "digitalocean_droplet" "web" {
image = "ubuntu-14-04-x64" image = "ubuntu-14-04-x64"
name = "web-1" name = "web-1"
@ -28,12 +28,12 @@ resource "digitalocean_droplet" "web" {
The following arguments are supported: The following arguments are supported:
* `image` - (Required) The droplet image ID or slug. * `image` - (Required) The Droplet image ID or slug.
* `name` - (Required) The droplet name * `name` - (Required) The Droplet name
* `region` - (Required) The region to start in * `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 * `backups` - (Optional) Boolean controlling if backups are made. Defaults to
false. 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` (Optional) - A string of the desired User Data for the Droplet.
User Data is currently only available in regions with metadata User Data is currently only available in regions with metadata
listed in their features. 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 ## Attributes Reference
The following attributes are exported: The following attributes are exported:
* `id` - The ID of the droplet * `id` - The ID of the Droplet
* `name`- The name of the droplet * `name`- The name of the Droplet
* `region` - The region of the droplet * `region` - The region of the Droplet
* `image` - The image of the droplet * `image` - The image of the Droplet
* `ipv6` - Is IPv6 enabled * `ipv6` - Is IPv6 enabled
* `ipv6_address` - The IPv6 address * `ipv6_address` - The IPv6 address
* `ipv6_address_private` - The private networking IPv6 address * `ipv6_address_private` - The private networking IPv6 address
@ -68,3 +69,4 @@ The following attributes are exported:
* `size` - The instance size * `size` - The instance size
* `status` - The status of the droplet * `status` - The status of the droplet
* `tags` - The tags associated with the droplet * `tags` - The tags associated with the droplet
* `volume_ids` - A list of the attached block storage volumes

View File

@ -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.

View File

@ -31,6 +31,9 @@
<li<%= sidebar_current("docs-do-resource-ssh-key") %>> <li<%= sidebar_current("docs-do-resource-ssh-key") %>>
<a href="/docs/providers/do/r/ssh_key.html">digitalocean_ssh_key</a> <a href="/docs/providers/do/r/ssh_key.html">digitalocean_ssh_key</a>
</li>
<li<%= sidebar_current("docs-do-resource-volume") %>>
<a href="/docs/providers/do/r/volume.html">digitalocean_volume</a>
</li> </li>
</ul> </ul>
</li> </li>