mirror of
https://github.com/opentofu/opentofu.git
synced 2025-01-08 15:13:56 -06:00
ccb34b702d
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>
623 lines
16 KiB
Go
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
|
|
}
|