opentofu/builtin/providers/digitalocean/resource_digitalocean_droplet.go
Julien Pivotto ccb34b702d provider/digitalocean: prevent new resources when using ID's of images with slugs (#13879)
When you specify the ID of an image that has a slug, terraform would
store its slug to the state, hence it would always recreate the image.

This commit fixes it by storing the image as an ID when it is specified
by and ID by the user, ignoring the slug.

Closes #12751.
Fixes #12255.

Signed-off-by: Julien Pivotto <roidelapluie@inuits.eu>
2017-04-27 18:48:58 +01:00

623 lines
16 KiB
Go

package digitalocean
import (
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/digitalocean/godo"
"github.com/hashicorp/terraform/helper/resource"
"github.com/hashicorp/terraform/helper/schema"
)
func resourceDigitalOceanDroplet() *schema.Resource {
return &schema.Resource{
Create: resourceDigitalOceanDropletCreate,
Read: resourceDigitalOceanDropletRead,
Update: resourceDigitalOceanDropletUpdate,
Delete: resourceDigitalOceanDropletDelete,
Importer: &schema.ResourceImporter{
State: schema.ImportStatePassthrough,
},
Schema: map[string]*schema.Schema{
"image": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
},
"name": {
Type: schema.TypeString,
Required: true,
},
"region": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
StateFunc: func(val interface{}) string {
// DO API V2 region slug is always lowercase
return strings.ToLower(val.(string))
},
},
"size": {
Type: schema.TypeString,
Required: true,
StateFunc: func(val interface{}) string {
// DO API V2 size slug is always lowercase
return strings.ToLower(val.(string))
},
},
"disk": {
Type: schema.TypeInt,
Computed: true,
},
"vcpus": {
Type: schema.TypeInt,
Computed: true,
},
"price_hourly": {
Type: schema.TypeFloat,
Computed: true,
},
"price_monthly": {
Type: schema.TypeFloat,
Computed: true,
},
"resize_disk": {
Type: schema.TypeBool,
Optional: true,
Default: true,
},
"status": {
Type: schema.TypeString,
Computed: true,
},
"locked": {
Type: schema.TypeString,
Computed: true,
},
"backups": {
Type: schema.TypeBool,
Optional: true,
},
"ipv6": {
Type: schema.TypeBool,
Optional: true,
},
"ipv6_address": {
Type: schema.TypeString,
Computed: true,
StateFunc: func(val interface{}) string {
return strings.ToLower(val.(string))
},
},
"ipv6_address_private": {
Type: schema.TypeString,
Computed: true,
},
"private_networking": {
Type: schema.TypeBool,
Optional: true,
},
"ipv4_address": {
Type: schema.TypeString,
Computed: true,
},
"ipv4_address_private": {
Type: schema.TypeString,
Computed: true,
},
"ssh_keys": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"tags": {
Type: schema.TypeList,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"user_data": {
Type: schema.TypeString,
Optional: true,
ForceNew: true,
},
"volume_ids": {
Type: schema.TypeList,
Elem: &schema.Schema{Type: schema.TypeString},
Optional: true,
},
},
}
}
func resourceDigitalOceanDropletCreate(d *schema.ResourceData, meta interface{}) error {
client := meta.(*godo.Client)
// Build up our creation options
opts := &godo.DropletCreateRequest{
Image: godo.DropletCreateImage{
Slug: d.Get("image").(string),
},
Name: d.Get("name").(string),
Region: d.Get("region").(string),
Size: d.Get("size").(string),
}
if attr, ok := d.GetOk("backups"); ok {
opts.Backups = attr.(bool)
}
if attr, ok := d.GetOk("ipv6"); ok {
opts.IPv6 = attr.(bool)
}
if attr, ok := d.GetOk("private_networking"); ok {
opts.PrivateNetworking = attr.(bool)
}
if attr, ok := d.GetOk("user_data"); ok {
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 {
opts.SSHKeys = make([]godo.DropletCreateSSHKey, 0, sshKeys)
for i := 0; i < sshKeys; i++ {
key := fmt.Sprintf("ssh_keys.%d", i)
sshKeyRef := d.Get(key).(string)
var sshKey godo.DropletCreateSSHKey
// sshKeyRef can be either an ID or a fingerprint
if id, err := strconv.Atoi(sshKeyRef); err == nil {
sshKey.ID = id
} else {
sshKey.Fingerprint = sshKeyRef
}
opts.SSHKeys = append(opts.SSHKeys, sshKey)
}
}
log.Printf("[DEBUG] Droplet create configuration: %#v", opts)
droplet, _, err := client.Droplets.Create(opts)
if err != nil {
return fmt.Errorf("Error creating droplet: %s", err)
}
// Assign the droplets id
d.SetId(strconv.Itoa(droplet.ID))
log.Printf("[INFO] Droplet ID: %s", d.Id())
_, err = WaitForDropletAttribute(d, "active", []string{"new"}, "status", meta)
if err != nil {
return fmt.Errorf(
"Error waiting for droplet (%s) to become ready: %s", d.Id(), err)
}
// droplet needs to be active in order to set tags
err = setTags(client, d)
if err != nil {
return fmt.Errorf("Error setting tags: %s", err)
}
return resourceDigitalOceanDropletRead(d, meta)
}
func resourceDigitalOceanDropletRead(d *schema.ResourceData, meta interface{}) error {
client := meta.(*godo.Client)
id, err := strconv.Atoi(d.Id())
if err != nil {
return fmt.Errorf("invalid droplet id: %v", err)
}
// Retrieve the droplet properties for updating the state
droplet, resp, err := client.Droplets.Get(id)
if err != nil {
// check if the droplet no longer exists.
if resp != nil && resp.StatusCode == 404 {
log.Printf("[WARN] DigitalOcean Droplet (%s) not found", d.Id())
d.SetId("")
return nil
}
return fmt.Errorf("Error retrieving droplet: %s", err)
}
_, err = strconv.Atoi(d.Get("image").(string))
if err == nil || droplet.Image.Slug == "" {
// The image field is provided as an ID (number), or
// the image bash no slug. In both cases we store it as an ID.
d.Set("image", droplet.Image.ID)
} else {
d.Set("image", droplet.Image.Slug)
}
d.Set("name", droplet.Name)
d.Set("region", droplet.Region.Slug)
d.Set("size", droplet.Size.Slug)
d.Set("price_hourly", droplet.Size.PriceHourly)
d.Set("price_monthly", droplet.Size.PriceMonthly)
d.Set("disk", droplet.Disk)
d.Set("vcpus", droplet.Vcpus)
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", strings.ToLower(publicIPv6))
d.Set("ipv6_address_private", findIPv6AddrByType(droplet, "private"))
}
d.Set("ipv4_address", findIPv4AddrByType(droplet, "public"))
if privateIPv4 := findIPv4AddrByType(droplet, "private"); privateIPv4 != "" {
d.Set("private_networking", true)
d.Set("ipv4_address_private", privateIPv4)
}
// Initialize the connection info
d.SetConnInfo(map[string]string{
"type": "ssh",
"host": findIPv4AddrByType(droplet, "public"),
})
d.Set("tags", droplet.Tags)
return nil
}
func findIPv6AddrByType(d *godo.Droplet, addrType string) string {
for _, addr := range d.Networks.V6 {
if addr.Type == addrType {
return addr.IPAddress
}
}
return ""
}
func findIPv4AddrByType(d *godo.Droplet, addrType string) string {
for _, addr := range d.Networks.V4 {
if addr.Type == addrType {
return addr.IPAddress
}
}
return ""
}
func resourceDigitalOceanDropletUpdate(d *schema.ResourceData, meta interface{}) error {
client := meta.(*godo.Client)
id, err := strconv.Atoi(d.Id())
if err != nil {
return fmt.Errorf("invalid droplet id: %v", err)
}
resize_disk := d.Get("resize_disk").(bool)
if d.HasChange("size") || d.HasChange("resize_disk") && resize_disk {
newSize := d.Get("size")
_, _, err = client.DropletActions.PowerOff(id)
if err != nil && !strings.Contains(err.Error(), "Droplet is already powered off") {
return fmt.Errorf(
"Error powering off droplet (%s): %s", d.Id(), err)
}
// Wait for power off
_, err = WaitForDropletAttribute(d, "off", []string{"active"}, "status", client)
if err != nil {
return fmt.Errorf(
"Error waiting for droplet (%s) to become powered off: %s", d.Id(), err)
}
// Resize the droplet
action, _, err := client.DropletActions.Resize(id, newSize.(string), resize_disk)
if err != nil {
newErr := powerOnAndWait(d, meta)
if newErr != nil {
return fmt.Errorf(
"Error powering on droplet (%s) after failed resize: %s", d.Id(), err)
}
return fmt.Errorf(
"Error resizing droplet (%s): %s", d.Id(), err)
}
// Wait for the resize action to complete.
if err := waitForAction(client, action); err != nil {
newErr := powerOnAndWait(d, meta)
if newErr != nil {
return fmt.Errorf(
"Error powering on droplet (%s) after waiting for resize to finish: %s", d.Id(), err)
}
return fmt.Errorf(
"Error waiting for resize droplet (%s) to finish: %s", d.Id(), err)
}
_, _, err = client.DropletActions.PowerOn(id)
if err != nil {
return fmt.Errorf(
"Error powering on droplet (%s) after resize: %s", d.Id(), err)
}
// Wait for power off
_, err = WaitForDropletAttribute(d, "active", []string{"off"}, "status", meta)
if err != nil {
return err
}
}
if d.HasChange("name") {
oldName, newName := d.GetChange("name")
// Rename the droplet
_, _, err = client.DropletActions.Rename(id, newName.(string))
if err != nil {
return fmt.Errorf(
"Error renaming droplet (%s): %s", d.Id(), err)
}
// Wait for the name to change
_, err = WaitForDropletAttribute(
d, newName.(string), []string{"", oldName.(string)}, "name", meta)
if err != nil {
return fmt.Errorf(
"Error waiting for rename droplet (%s) to finish: %s", d.Id(), err)
}
}
// As there is no way to disable private networking,
// we only check if it needs to be enabled
if d.HasChange("private_networking") && d.Get("private_networking").(bool) {
_, _, err = client.DropletActions.EnablePrivateNetworking(id)
if err != nil {
return fmt.Errorf(
"Error enabling private networking for droplet (%s): %s", d.Id(), err)
}
// Wait for the private_networking to turn on
_, err = WaitForDropletAttribute(
d, "true", []string{"", "false"}, "private_networking", meta)
return fmt.Errorf(
"Error waiting for private networking to be enabled on for droplet (%s): %s", d.Id(), err)
}
// As there is no way to disable IPv6, we only check if it needs to be enabled
if d.HasChange("ipv6") && d.Get("ipv6").(bool) {
_, _, err = client.DropletActions.EnableIPv6(id)
if err != nil {
return fmt.Errorf(
"Error turning on ipv6 for droplet (%s): %s", d.Id(), err)
}
// Wait for ipv6 to turn on
_, err = WaitForDropletAttribute(
d, "true", []string{"", "false"}, "ipv6", meta)
if err != nil {
return fmt.Errorf(
"Error waiting for ipv6 to be turned on for droplet (%s): %s", d.Id(), err)
}
}
if d.HasChange("tags") {
err = setTags(client, d)
if err != nil {
return fmt.Errorf("Error updating tags: %s", err)
}
}
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)
}
func resourceDigitalOceanDropletDelete(d *schema.ResourceData, meta interface{}) error {
client := meta.(*godo.Client)
id, err := strconv.Atoi(d.Id())
if err != nil {
return fmt.Errorf("invalid droplet id: %v", err)
}
_, err = WaitForDropletAttribute(
d, "false", []string{"", "true"}, "locked", meta)
if err != nil {
return fmt.Errorf(
"Error waiting for droplet to be unlocked for destroy (%s): %s", d.Id(), err)
}
log.Printf("[INFO] Deleting droplet: %s", d.Id())
// Destroy the droplet
_, err = client.Droplets.Delete(id)
// Handle remotely destroyed droplets
if err != nil && strings.Contains(err.Error(), "404 Not Found") {
return nil
}
if err != nil {
return fmt.Errorf("Error deleting droplet: %s", err)
}
return nil
}
func WaitForDropletAttribute(
d *schema.ResourceData, target string, pending []string, attribute string, meta interface{}) (interface{}, error) {
// Wait for the droplet so we can get the networking attributes
// that show up after a while
log.Printf(
"[INFO] Waiting for droplet (%s) to have %s of %s",
d.Id(), attribute, target)
stateConf := &resource.StateChangeConf{
Pending: pending,
Target: []string{target},
Refresh: newDropletStateRefreshFunc(d, attribute, meta),
Timeout: 60 * time.Minute,
Delay: 10 * time.Second,
MinTimeout: 3 * time.Second,
// This is a hack around DO API strangeness.
// https://github.com/hashicorp/terraform/issues/481
//
NotFoundChecks: 60,
}
return stateConf.WaitForState()
}
// TODO This function still needs a little more refactoring to make it
// cleaner and more efficient
func newDropletStateRefreshFunc(
d *schema.ResourceData, attribute string, meta interface{}) resource.StateRefreshFunc {
client := meta.(*godo.Client)
return func() (interface{}, string, error) {
id, err := strconv.Atoi(d.Id())
if err != nil {
return nil, "", err
}
err = resourceDigitalOceanDropletRead(d, meta)
if err != nil {
return nil, "", err
}
// If the droplet is locked, continue waiting. We can
// only perform actions on unlocked droplets, so it's
// pointless to look at that status
if d.Get("locked").(string) == "true" {
log.Println("[DEBUG] Droplet is locked, skipping status check and retrying")
return nil, "", nil
}
// See if we can access our attribute
if attr, ok := d.GetOk(attribute); ok {
// Retrieve the droplet properties
droplet, _, err := client.Droplets.Get(id)
if err != nil {
return nil, "", fmt.Errorf("Error retrieving droplet: %s", err)
}
return &droplet, attr.(string), nil
}
return nil, "", nil
}
}
// Powers on the droplet and waits for it to be active
func powerOnAndWait(d *schema.ResourceData, meta interface{}) error {
id, err := strconv.Atoi(d.Id())
if err != nil {
return fmt.Errorf("invalid droplet id: %v", err)
}
client := meta.(*godo.Client)
_, _, err = client.DropletActions.PowerOn(id)
if err != nil {
return err
}
// Wait for power on
_, err = WaitForDropletAttribute(d, "active", []string{"off"}, "status", client)
if err != nil {
return err
}
return nil
}