diff --git a/digitalocean/datasource_digitalocean_floating_ip.go b/digitalocean/datasource_digitalocean_floating_ip.go index 80083e15b..a9bb6b9d5 100644 --- a/digitalocean/datasource_digitalocean_floating_ip.go +++ b/digitalocean/datasource_digitalocean_floating_ip.go @@ -8,11 +8,12 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) -func dataSourceDigitalOceanFloatingIp() *schema.Resource { +func dataSourceDigitalOceanFloatingIP() *schema.Resource { return &schema.Resource{ - ReadContext: dataSourceDigitalOceanFloatingIpRead, + // TODO: Uncomment when dates for deprecation timeline are set. + // DeprecationMessage: "This data source is deprecated and will be removed in a future release. Please use digitalocean_reserved_ip instead.", + ReadContext: dataSourceDigitalOceanFloatingIPRead, Schema: map[string]*schema.Schema{ - "ip_address": { Type: schema.TypeString, Required: true, @@ -39,27 +40,12 @@ func dataSourceDigitalOceanFloatingIp() *schema.Resource { } } -func dataSourceDigitalOceanFloatingIpRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*CombinedConfig).godoClient() - - ipAddress := d.Get("ip_address").(string) - - floatingIp, resp, err := client.FloatingIPs.Get(context.Background(), ipAddress) +func dataSourceDigitalOceanFloatingIPRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + err := dataSourceDigitalOceanReservedIPRead(ctx, d, meta) if err != nil { - if resp != nil && resp.StatusCode == 404 { - return diag.Errorf("floating ip not found: %s", err) - } - return diag.Errorf("Error retrieving floating ip: %s", err) - } - - d.SetId(floatingIp.IP) - d.Set("ip_address", floatingIp.IP) - d.Set("urn", floatingIp.URN()) - d.Set("region", floatingIp.Region.Slug) - - if floatingIp.Droplet != nil { - d.Set("droplet_id", floatingIp.Droplet.ID) + return err } + reservedIPURNtoFloatingIPURN(d) return nil } diff --git a/digitalocean/datasource_digitalocean_reserved_ip.go b/digitalocean/datasource_digitalocean_reserved_ip.go new file mode 100644 index 000000000..57a69796b --- /dev/null +++ b/digitalocean/datasource_digitalocean_reserved_ip.go @@ -0,0 +1,46 @@ +package digitalocean + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func dataSourceDigitalOceanReservedIP() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceDigitalOceanReservedIPRead, + Schema: map[string]*schema.Schema{ + "ip_address": { + Type: schema.TypeString, + Required: true, + Description: "reserved ip address", + ValidateFunc: validation.NoZeroValues, + }, + // computed attributes + "urn": { + Type: schema.TypeString, + Computed: true, + Description: "the uniform resource name for the reserved ip", + }, + "region": { + Type: schema.TypeString, + Computed: true, + Description: "the region that the reserved ip is reserved to", + }, + "droplet_id": { + Type: schema.TypeInt, + Computed: true, + Description: "the droplet id that the reserved ip has been assigned to.", + }, + }, + } +} + +func dataSourceDigitalOceanReservedIPRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + ipAddress := d.Get("ip_address").(string) + d.SetId(ipAddress) + + return resourceDigitalOceanReservedIPRead(ctx, d, meta) +} diff --git a/digitalocean/datasource_digitalocean_reserved_ip_test.go b/digitalocean/datasource_digitalocean_reserved_ip_test.go new file mode 100644 index 000000000..e9806c535 --- /dev/null +++ b/digitalocean/datasource_digitalocean_reserved_ip_test.go @@ -0,0 +1,108 @@ +package digitalocean + +import ( + "context" + "fmt" + "regexp" + "testing" + + "github.com/digitalocean/godo" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccDataSourceDigitalOceanReservedIP_Basic(t *testing.T) { + var reservedIP godo.ReservedIP + + expectedURNRegEx, _ := regexp.Compile(`do:reservedip:(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$`) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccCheckDataSourceDigitalOceanReservedIPConfig_Basic, + Check: resource.ComposeTestCheckFunc( + testAccCheckDataSourceDigitalOceanReservedIPExists("data.digitalocean_reserved_ip.foobar", &reservedIP), + resource.TestCheckResourceAttrSet( + "data.digitalocean_reserved_ip.foobar", "ip_address"), + resource.TestCheckResourceAttr( + "data.digitalocean_reserved_ip.foobar", "region", "nyc3"), + resource.TestMatchResourceAttr("data.digitalocean_reserved_ip.foobar", "urn", expectedURNRegEx), + ), + }, + }, + }) +} + +func TestAccDataSourceDigitalOceanReservedIP_FindsFloatingIP(t *testing.T) { + var reservedIP godo.ReservedIP + + expectedURNRegEx, _ := regexp.Compile(`do:reservedip:(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$`) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccCheckDataSourceDigitalOceanReservedIPConfig_FindsFloatingIP, + Check: resource.ComposeTestCheckFunc( + testAccCheckDataSourceDigitalOceanReservedIPExists("data.digitalocean_reserved_ip.foobar", &reservedIP), + resource.TestCheckResourceAttrSet( + "data.digitalocean_reserved_ip.foobar", "ip_address"), + resource.TestCheckResourceAttr( + "data.digitalocean_reserved_ip.foobar", "region", "nyc3"), + resource.TestMatchResourceAttr("data.digitalocean_reserved_ip.foobar", "urn", expectedURNRegEx), + ), + }, + }, + }) +} + +func testAccCheckDataSourceDigitalOceanReservedIPExists(n string, reservedIP *godo.ReservedIP) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No reserved IP ID is set") + } + + client := testAccProvider.Meta().(*CombinedConfig).godoClient() + + foundReservedIP, _, err := client.ReservedIPs.Get(context.Background(), rs.Primary.ID) + + if err != nil { + return err + } + + if foundReservedIP.IP != rs.Primary.ID { + return fmt.Errorf("reserved IP not found") + } + + *reservedIP = *foundReservedIP + + return nil + } +} + +const testAccCheckDataSourceDigitalOceanReservedIPConfig_FindsFloatingIP = ` +resource "digitalocean_floating_ip" "foo" { + region = "nyc3" +} + +data "digitalocean_reserved_ip" "foobar" { + ip_address = digitalocean_floating_ip.foo.ip_address +}` + +const testAccCheckDataSourceDigitalOceanReservedIPConfig_Basic = ` +resource "digitalocean_reserved_ip" "foo" { + region = "nyc3" +} + +data "digitalocean_reserved_ip" "foobar" { + ip_address = digitalocean_reserved_ip.foo.ip_address +}` diff --git a/digitalocean/import_digitalocean_reserved_ip_test.go b/digitalocean/import_digitalocean_reserved_ip_test.go new file mode 100644 index 000000000..48e2a7cca --- /dev/null +++ b/digitalocean/import_digitalocean_reserved_ip_test.go @@ -0,0 +1,51 @@ +package digitalocean + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccDigitalOceanReservedIP_importBasicRegion(t *testing.T) { + resourceName := "digitalocean_reserved_ip.foobar" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanReservedIPDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckDigitalOceanReservedIPConfig_region, + }, + + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccDigitalOceanReservedIP_importBasicDroplet(t *testing.T) { + resourceName := "digitalocean_reserved_ip.foobar" + rInt := acctest.RandInt() + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanReservedIPDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckDigitalOceanReservedIPConfig_droplet(rInt), + }, + + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} diff --git a/digitalocean/provider.go b/digitalocean/provider.go index 922f1f615..39e41c319 100644 --- a/digitalocean/provider.go +++ b/digitalocean/provider.go @@ -61,7 +61,7 @@ func Provider() *schema.Provider { "digitalocean_droplets": dataSourceDigitalOceanDroplets(), "digitalocean_droplet_snapshot": dataSourceDigitalOceanDropletSnapshot(), "digitalocean_firewall": dataSourceDigitalOceanFirewall(), - "digitalocean_floating_ip": dataSourceDigitalOceanFloatingIp(), + "digitalocean_floating_ip": dataSourceDigitalOceanFloatingIP(), "digitalocean_image": dataSourceDigitalOceanImage(), "digitalocean_images": dataSourceDigitalOceanImages(), "digitalocean_kubernetes_cluster": dataSourceDigitalOceanKubernetesCluster(), @@ -73,6 +73,7 @@ func Provider() *schema.Provider { "digitalocean_records": dataSourceDigitalOceanRecords(), "digitalocean_region": dataSourceDigitalOceanRegion(), "digitalocean_regions": dataSourceDigitalOceanRegions(), + "digitalocean_reserved_ip": dataSourceDigitalOceanReservedIP(), "digitalocean_sizes": dataSourceDigitalOceanSizes(), "digitalocean_spaces_bucket": dataSourceDigitalOceanSpacesBucket(), "digitalocean_spaces_buckets": dataSourceDigitalOceanSpacesBuckets(), @@ -103,8 +104,8 @@ func Provider() *schema.Provider { "digitalocean_droplet": resourceDigitalOceanDroplet(), "digitalocean_droplet_snapshot": resourceDigitalOceanDropletSnapshot(), "digitalocean_firewall": resourceDigitalOceanFirewall(), - "digitalocean_floating_ip": resourceDigitalOceanFloatingIp(), - "digitalocean_floating_ip_assignment": resourceDigitalOceanFloatingIpAssignment(), + "digitalocean_floating_ip": resourceDigitalOceanFloatingIP(), + "digitalocean_floating_ip_assignment": resourceDigitalOceanFloatingIPAssignment(), "digitalocean_kubernetes_cluster": resourceDigitalOceanKubernetesCluster(), "digitalocean_kubernetes_node_pool": resourceDigitalOceanKubernetesNodePool(), "digitalocean_loadbalancer": resourceDigitalOceanLoadbalancer(), @@ -112,6 +113,8 @@ func Provider() *schema.Provider { "digitalocean_project": resourceDigitalOceanProject(), "digitalocean_project_resources": resourceDigitalOceanProjectResources(), "digitalocean_record": resourceDigitalOceanRecord(), + "digitalocean_reserved_ip": resourceDigitalOceanReservedIP(), + "digitalocean_reserved_ip_assignment": resourceDigitalOceanReservedIPAssignment(), "digitalocean_spaces_bucket": resourceDigitalOceanBucket(), "digitalocean_spaces_bucket_object": resourceDigitalOceanSpacesBucketObject(), "digitalocean_spaces_bucket_policy": resourceDigitalOceanSpacesBucketPolicy(), diff --git a/digitalocean/resource_digitalocean_floating_ip.go b/digitalocean/resource_digitalocean_floating_ip.go index a47d8e789..9dd9fd78d 100644 --- a/digitalocean/resource_digitalocean_floating_ip.go +++ b/digitalocean/resource_digitalocean_floating_ip.go @@ -2,26 +2,24 @@ package digitalocean import ( "context" - "fmt" - "log" "strings" - "time" "github.com/digitalocean/godo" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) -func resourceDigitalOceanFloatingIp() *schema.Resource { +func resourceDigitalOceanFloatingIP() *schema.Resource { return &schema.Resource{ - CreateContext: resourceDigitalOceanFloatingIpCreate, - UpdateContext: resourceDigitalOceanFloatingIpUpdate, - ReadContext: resourceDigitalOceanFloatingIpRead, - DeleteContext: resourceDigitalOceanFloatingIpDelete, + // TODO: Uncomment when dates for deprecation timeline are set. + // DeprecationMessage: "This resource is deprecated and will be removed in a future release. Please use digitalocean_reserved_ip instead.", + CreateContext: resourceDigitalOceanFloatingIPCreate, + UpdateContext: resourceDigitalOceanFloatingIPUpdate, + ReadContext: resourceDigitalOceanFloatingIPRead, + DeleteContext: resourceDigitalOceanReservedIPDelete, Importer: &schema.ResourceImporter{ - State: resourceDigitalOceanFloatingIpImport, + StateContext: resourceDigitalOceanFloatingIPImport, }, Schema: map[string]*schema.Schema{ @@ -54,189 +52,49 @@ func resourceDigitalOceanFloatingIp() *schema.Resource { } } -func resourceDigitalOceanFloatingIpCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*CombinedConfig).godoClient() - - log.Printf("[INFO] Create a FloatingIP In a Region") - regionOpts := &godo.FloatingIPCreateRequest{ - Region: d.Get("region").(string), - } - - log.Printf("[DEBUG] FloatingIP Create: %#v", regionOpts) - floatingIp, _, err := client.FloatingIPs.Create(context.Background(), regionOpts) +func resourceDigitalOceanFloatingIPCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + err := resourceDigitalOceanReservedIPCreate(ctx, d, meta) if err != nil { - return diag.Errorf("Error creating FloatingIP: %s", err) - } - - d.SetId(floatingIp.IP) - - if v, ok := d.GetOk("droplet_id"); ok { - - log.Printf("[INFO] Assigning the Floating IP to the Droplet %d", v.(int)) - action, _, err := client.FloatingIPActions.Assign(context.Background(), d.Id(), v.(int)) - if err != nil { - return diag.Errorf( - "Error Assigning FloatingIP (%s) to the droplet: %s", d.Id(), err) - } - - _, unassignedErr := waitForFloatingIPReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) - if unassignedErr != nil { - return diag.Errorf( - "Error waiting for FloatingIP (%s) to be Assigned: %s", d.Id(), unassignedErr) - } - } - - return resourceDigitalOceanFloatingIpRead(ctx, d, meta) -} - -func resourceDigitalOceanFloatingIpUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*CombinedConfig).godoClient() - - if d.HasChange("droplet_id") { - if v, ok := d.GetOk("droplet_id"); ok { - log.Printf("[INFO] Assigning the Floating IP %s to the Droplet %d", d.Id(), v.(int)) - action, _, err := client.FloatingIPActions.Assign(context.Background(), d.Id(), v.(int)) - if err != nil { - return diag.Errorf( - "Error Assigning FloatingIP (%s) to the droplet: %s", d.Id(), err) - } - - _, unassignedErr := waitForFloatingIPReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) - if unassignedErr != nil { - return diag.Errorf( - "Error waiting for FloatingIP (%s) to be Assigned: %s", d.Id(), unassignedErr) - } - } else { - log.Printf("[INFO] Unassigning the Floating IP %s", d.Id()) - action, _, err := client.FloatingIPActions.Unassign(context.Background(), d.Id()) - if err != nil { - return diag.Errorf( - "Error unassigning FloatingIP (%s): %s", d.Id(), err) - } - - _, unassignedErr := waitForFloatingIPReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) - if unassignedErr != nil { - return diag.Errorf( - "Error waiting for FloatingIP (%s) to be Unassigned: %s", d.Id(), unassignedErr) - } - } - } - - return resourceDigitalOceanFloatingIpRead(ctx, d, meta) -} - -func resourceDigitalOceanFloatingIpRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*CombinedConfig).godoClient() - - log.Printf("[INFO] Reading the details of the FloatingIP %s", d.Id()) - floatingIp, resp, err := client.FloatingIPs.Get(context.Background(), d.Id()) - if resp.StatusCode != 404 { - if err != nil { - return diag.Errorf("Error retrieving FloatingIP: %s", err) - } - - if _, ok := d.GetOk("droplet_id"); ok && floatingIp.Droplet != nil { - log.Printf("[INFO] A droplet was detected on the FloatingIP so setting the Region based on the Droplet") - log.Printf("[INFO] The region of the Droplet is %s", floatingIp.Droplet.Region.Slug) - d.Set("region", floatingIp.Droplet.Region.Slug) - d.Set("droplet_id", floatingIp.Droplet.ID) - } else { - d.Set("region", floatingIp.Region.Slug) - } - - d.Set("ip_address", floatingIp.IP) - d.Set("urn", floatingIp.URN()) - } else { - d.SetId("") + return err } + reservedIPURNtoFloatingIPURN(d) return nil } -func resourceDigitalOceanFloatingIpImport(rs *schema.ResourceData, v interface{}) ([]*schema.ResourceData, error) { - client := v.(*CombinedConfig).godoClient() - floatingIp, resp, err := client.FloatingIPs.Get(context.Background(), rs.Id()) - if resp.StatusCode != 404 { - if err != nil { - return nil, err - } - - rs.Set("ip_address", floatingIp.IP) - rs.Set("urn", floatingIp.URN()) - rs.Set("region", floatingIp.Region.Slug) - - if floatingIp.Droplet != nil { - rs.Set("droplet_id", floatingIp.Droplet.ID) - } +func resourceDigitalOceanFloatingIPUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + err := resourceDigitalOceanReservedIPUpdate(ctx, d, meta) + if err != nil { + return err } + reservedIPURNtoFloatingIPURN(d) - return []*schema.ResourceData{rs}, nil + return nil } -func resourceDigitalOceanFloatingIpDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*CombinedConfig).godoClient() - - if _, ok := d.GetOk("droplet_id"); ok { - log.Printf("[INFO] Unassigning the Floating IP from the Droplet") - action, resp, err := client.FloatingIPActions.Unassign(context.Background(), d.Id()) - if resp.StatusCode != 422 { - if err != nil { - return diag.Errorf( - "Error unassigning FloatingIP (%s) from the droplet: %s", d.Id(), err) - } - - _, unassignedErr := waitForFloatingIPReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) - if unassignedErr != nil { - return diag.Errorf( - "Error waiting for FloatingIP (%s) to be unassigned: %s", d.Id(), unassignedErr) - } - } else { - log.Printf("[DEBUG] Couldn't unassign FloatingIP (%s) from droplet, possibly out of sync: %s", d.Id(), err) - } - } - - log.Printf("[INFO] Deleting FloatingIP: %s", d.Id()) - _, err := client.FloatingIPs.Delete(context.Background(), d.Id()) +func resourceDigitalOceanFloatingIPRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + err := resourceDigitalOceanReservedIPRead(ctx, d, meta) if err != nil { - return diag.Errorf("Error deleting FloatingIP: %s", err) + return err } + reservedIPURNtoFloatingIPURN(d) - d.SetId("") return nil } -func waitForFloatingIPReady( - ctx context.Context, d *schema.ResourceData, target string, pending []string, attribute string, meta interface{}, actionId int) (interface{}, error) { - log.Printf( - "[INFO] Waiting for FloatingIP (%s) to have %s of %s", - d.Id(), attribute, target) - - stateConf := &resource.StateChangeConf{ - Pending: pending, - Target: []string{target}, - Refresh: newFloatingIPStateRefreshFunc(d, attribute, meta, actionId), - Timeout: 60 * time.Minute, - Delay: 10 * time.Second, - MinTimeout: 3 * time.Second, - - NotFoundChecks: 60, +func resourceDigitalOceanFloatingIPImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + _, err := resourceDigitalOceanReservedIPImport(ctx, d, meta) + if err != nil { + return nil, err } + reservedIPURNtoFloatingIPURN(d) - return stateConf.WaitForStateContext(ctx) + return []*schema.ResourceData{d}, nil } -func newFloatingIPStateRefreshFunc( - d *schema.ResourceData, attribute string, meta interface{}, actionId int) resource.StateRefreshFunc { - client := meta.(*CombinedConfig).godoClient() - return func() (interface{}, string, error) { - - log.Printf("[INFO] Assigning the Floating IP to the Droplet") - action, _, err := client.FloatingIPActions.Get(context.Background(), d.Id(), actionId) - if err != nil { - return nil, "", fmt.Errorf("Error retrieving FloatingIP (%s) ActionId (%d): %s", d.Id(), actionId, err) - } - - log.Printf("[INFO] The FloatingIP Action Status is %s", action.Status) - return &action, action.Status, nil - } +// reservedIPURNtoFloatingIPURN re-formats a reserved IP URN as floating IP URN. +// TODO: Remove when the projects' API changes return values. +func reservedIPURNtoFloatingIPURN(d *schema.ResourceData) { + ip := d.Get("ip_address") + d.Set("urn", godo.FloatingIP{IP: ip.(string)}.URN()) } diff --git a/digitalocean/resource_digitalocean_floating_ip_assignment.go b/digitalocean/resource_digitalocean_floating_ip_assignment.go index 6e6b34058..1b08c8632 100644 --- a/digitalocean/resource_digitalocean_floating_ip_assignment.go +++ b/digitalocean/resource_digitalocean_floating_ip_assignment.go @@ -1,27 +1,19 @@ package digitalocean import ( - "context" - "errors" - "fmt" - "log" - "strconv" - "strings" - "time" - - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) -func resourceDigitalOceanFloatingIpAssignment() *schema.Resource { +func resourceDigitalOceanFloatingIPAssignment() *schema.Resource { return &schema.Resource{ - CreateContext: resourceDigitalOceanFloatingIpAssignmentCreate, - ReadContext: resourceDigitalOceanFloatingIpAssignmentRead, - DeleteContext: resourceDigitalOceanFloatingIpAssignmentDelete, + // TODO: Uncomment when dates for deprecation timeline are set. + // DeprecationMessage: "This resource is deprecated and will be removed in a future release. Please use digitalocean_reserved_ip_assignment instead.", + CreateContext: resourceDigitalOceanReservedIPAssignmentCreate, + ReadContext: resourceDigitalOceanReservedIPAssignmentRead, + DeleteContext: resourceDigitalOceanReservedIPAssignmentDelete, Importer: &schema.ResourceImporter{ - StateContext: resourceDigitalOceanFloatingIPAssignmentImport, + StateContext: resourceDigitalOceanReservedIPAssignmentImport, }, Schema: map[string]*schema.Schema{ @@ -41,131 +33,3 @@ func resourceDigitalOceanFloatingIpAssignment() *schema.Resource { }, } } - -func resourceDigitalOceanFloatingIpAssignmentCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*CombinedConfig).godoClient() - - ip_address := d.Get("ip_address").(string) - droplet_id := d.Get("droplet_id").(int) - - log.Printf("[INFO] Assigning the Floating IP (%s) to the Droplet %d", ip_address, droplet_id) - action, _, err := client.FloatingIPActions.Assign(context.Background(), ip_address, droplet_id) - if err != nil { - return diag.Errorf( - "Error Assigning FloatingIP (%s) to the droplet: %s", ip_address, err) - } - - _, unassignedErr := waitForFloatingIPAssignmentReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) - if unassignedErr != nil { - return diag.Errorf( - "Error waiting for FloatingIP (%s) to be Assigned: %s", ip_address, unassignedErr) - } - - d.SetId(resource.PrefixedUniqueId(fmt.Sprintf("%d-%s-", droplet_id, ip_address))) - return resourceDigitalOceanFloatingIpAssignmentRead(ctx, d, meta) -} - -func resourceDigitalOceanFloatingIpAssignmentRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*CombinedConfig).godoClient() - - ip_address := d.Get("ip_address").(string) - droplet_id := d.Get("droplet_id").(int) - - log.Printf("[INFO] Reading the details of the FloatingIP %s", ip_address) - floatingIp, _, err := client.FloatingIPs.Get(context.Background(), ip_address) - if err != nil { - return diag.Errorf("Error retrieving FloatingIP: %s", err) - } - - if floatingIp.Droplet == nil || floatingIp.Droplet.ID != droplet_id { - log.Printf("[INFO] A droplet was detected on the FloatingIP.") - d.SetId("") - } - - return nil -} - -func resourceDigitalOceanFloatingIpAssignmentDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*CombinedConfig).godoClient() - - ip_address := d.Get("ip_address").(string) - droplet_id := d.Get("droplet_id").(int) - - log.Printf("[INFO] Reading the details of the FloatingIP %s", ip_address) - floatingIp, _, err := client.FloatingIPs.Get(context.Background(), ip_address) - if err != nil { - return diag.Errorf("Error retrieving FloatingIP: %s", err) - } - - if floatingIp.Droplet.ID == droplet_id { - log.Printf("[INFO] Unassigning the Floating IP from the Droplet") - action, _, err := client.FloatingIPActions.Unassign(context.Background(), ip_address) - if err != nil { - return diag.Errorf("Error unassigning FloatingIP (%s) from the droplet: %s", ip_address, err) - } - - _, unassignedErr := waitForFloatingIPAssignmentReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) - if unassignedErr != nil { - return diag.Errorf( - "Error waiting for FloatingIP (%s) to be unassigned: %s", ip_address, unassignedErr) - } - } else { - log.Printf("[INFO] Floating IP already unassigned, removing from state.") - } - - d.SetId("") - return nil -} - -func waitForFloatingIPAssignmentReady( - ctx context.Context, d *schema.ResourceData, target string, pending []string, attribute string, meta interface{}, actionId int) (interface{}, error) { - log.Printf( - "[INFO] Waiting for FloatingIP (%s) to have %s of %s", - d.Get("ip_address").(string), attribute, target) - - stateConf := &resource.StateChangeConf{ - Pending: pending, - Target: []string{target}, - Refresh: newFloatingIPAssignmentStateRefreshFunc(d, attribute, meta, actionId), - Timeout: 60 * time.Minute, - Delay: 10 * time.Second, - MinTimeout: 3 * time.Second, - - NotFoundChecks: 60, - } - - return stateConf.WaitForStateContext(ctx) -} - -func newFloatingIPAssignmentStateRefreshFunc( - d *schema.ResourceData, attribute string, meta interface{}, actionId int) resource.StateRefreshFunc { - client := meta.(*CombinedConfig).godoClient() - return func() (interface{}, string, error) { - - log.Printf("[INFO] Refreshing the Floating IP state") - action, _, err := client.FloatingIPActions.Get(context.Background(), d.Get("ip_address").(string), actionId) - if err != nil { - return nil, "", fmt.Errorf("Error retrieving FloatingIP (%s) ActionId (%d): %s", d.Get("ip_address").(string), actionId, err) - } - - log.Printf("[INFO] The FloatingIP Action Status is %s", action.Status) - return &action, action.Status, nil - } -} - -func resourceDigitalOceanFloatingIPAssignmentImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { - if strings.Contains(d.Id(), ",") { - s := strings.Split(d.Id(), ",") - d.SetId(resource.PrefixedUniqueId(fmt.Sprintf("%s-%s-", s[1], s[0]))) - d.Set("ip_address", s[0]) - dropletID, err := strconv.Atoi(s[1]) - if err != nil { - return nil, err - } - d.Set("droplet_id", dropletID) - } else { - return nil, errors.New("must use the Floating IP and the ID of the Droplet joined with a comma (e.g. `ip_address,droplet_id`)") - } - - return []*schema.ResourceData{d}, nil -} diff --git a/digitalocean/resource_digitalocean_floating_ip_assignment_test.go b/digitalocean/resource_digitalocean_floating_ip_assignment_test.go index 12accba39..a94227564 100644 --- a/digitalocean/resource_digitalocean_floating_ip_assignment_test.go +++ b/digitalocean/resource_digitalocean_floating_ip_assignment_test.go @@ -121,7 +121,7 @@ resource "digitalocean_floating_ip" "foobar" { resource "digitalocean_droplet" "foobar" { count = 2 - name = "foobar-${count.index}" + name = "tf-acc-test-${count.index}" size = "s-1vcpu-1gb" image = "centos-7-x64" region = "nyc3" @@ -142,7 +142,7 @@ resource "digitalocean_floating_ip" "foobar" { resource "digitalocean_droplet" "foobar" { count = 2 - name = "foobar-${count.index}" + name = "tf-acc-test-${count.index}" size = "s-1vcpu-1gb" image = "centos-7-x64" region = "nyc3" @@ -163,7 +163,7 @@ resource "digitalocean_floating_ip" "foobar" { resource "digitalocean_droplet" "foobar" { count = 2 - name = "foobar-${count.index}" + name = "tf-acc-test-${count.index}" size = "s-1vcpu-1gb" image = "centos-7-x64" region = "nyc3" @@ -175,7 +175,7 @@ resource "digitalocean_droplet" "foobar" { var testAccCheckDigitalOceanFloatingIPAssignmentConfig_createBeforeDestroy = ` resource "digitalocean_droplet" "foobar" { image = "centos-7-x64" - name = "foo-bar" + name = "tf-acc-test" region = "nyc3" size = "s-1vcpu-1gb" @@ -201,7 +201,7 @@ resource "digitalocean_floating_ip_assignment" "foobar" { var testAccCheckDigitalOceanFloatingIPAssignmentConfig_createBeforeDestroyReassign = ` resource "digitalocean_droplet" "foobar" { image = "ubuntu-18-04-x64" - name = "foobar" + name = "tf-acc-test" region = "nyc3" size = "s-1vcpu-1gb" diff --git a/digitalocean/resource_digitalocean_floating_ip_test.go b/digitalocean/resource_digitalocean_floating_ip_test.go index 17868b60a..497a3724a 100644 --- a/digitalocean/resource_digitalocean_floating_ip_test.go +++ b/digitalocean/resource_digitalocean_floating_ip_test.go @@ -160,7 +160,7 @@ resource "digitalocean_floating_ip" "foobar" { func testAccCheckDigitalOceanFloatingIPConfig_droplet(rInt int) string { return fmt.Sprintf(` resource "digitalocean_droplet" "foobar" { - name = "foobar-%d" + name = "tf-acc-test-%d" size = "s-1vcpu-1gb" image = "centos-7-x64" region = "nyc3" @@ -177,7 +177,7 @@ resource "digitalocean_floating_ip" "foobar" { func testAccCheckDigitalOceanFloatingIPConfig_Reassign(rInt int) string { return fmt.Sprintf(` resource "digitalocean_droplet" "baz" { - name = "baz-%d" + name = "tf-acc-test-%d" size = "s-1vcpu-1gb" image = "centos-7-x64" region = "nyc3" @@ -194,7 +194,7 @@ resource "digitalocean_floating_ip" "foobar" { func testAccCheckDigitalOceanFloatingIPConfig_Unassign(rInt int) string { return fmt.Sprintf(` resource "digitalocean_droplet" "baz" { - name = "baz-%d" + name = "tf-acc-test-%d" size = "s-1vcpu-1gb" image = "centos-7-x64" region = "nyc3" diff --git a/digitalocean/resource_digitalocean_reserved_ip.go b/digitalocean/resource_digitalocean_reserved_ip.go new file mode 100644 index 000000000..586ce28ea --- /dev/null +++ b/digitalocean/resource_digitalocean_reserved_ip.go @@ -0,0 +1,240 @@ +package digitalocean + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/digitalocean/godo" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceDigitalOceanReservedIP() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceDigitalOceanReservedIPCreate, + UpdateContext: resourceDigitalOceanReservedIPUpdate, + ReadContext: resourceDigitalOceanReservedIPRead, + DeleteContext: resourceDigitalOceanReservedIPDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceDigitalOceanReservedIPImport, + }, + + Schema: map[string]*schema.Schema{ + "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)) + }, + }, + "urn": { + Type: schema.TypeString, + Computed: true, + Description: "the uniform resource name for the reserved ip", + }, + "ip_address": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ValidateFunc: validation.IsIPv4Address, + }, + "droplet_id": { + Type: schema.TypeInt, + Optional: true, + }, + }, + } +} + +func resourceDigitalOceanReservedIPCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*CombinedConfig).godoClient() + + log.Printf("[INFO] Creating a reserved IP in a region") + regionOpts := &godo.ReservedIPCreateRequest{ + Region: d.Get("region").(string), + } + + log.Printf("[DEBUG] Reserved IP create: %#v", regionOpts) + reservedIP, _, err := client.ReservedIPs.Create(context.Background(), regionOpts) + if err != nil { + return diag.Errorf("Error creating reserved IP: %s", err) + } + + d.SetId(reservedIP.IP) + + if v, ok := d.GetOk("droplet_id"); ok { + log.Printf("[INFO] Assigning the reserved IP to the Droplet %d", v.(int)) + action, _, err := client.ReservedIPActions.Assign(context.Background(), d.Id(), v.(int)) + if err != nil { + return diag.Errorf( + "Error Assigning reserved IP (%s) to the Droplet: %s", d.Id(), err) + } + + _, unassignedErr := waitForReservedIPReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) + if unassignedErr != nil { + return diag.Errorf( + "Error waiting for reserved IP (%s) to be assigned: %s", d.Id(), unassignedErr) + } + } + + return resourceDigitalOceanReservedIPRead(ctx, d, meta) +} + +func resourceDigitalOceanReservedIPUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*CombinedConfig).godoClient() + + if d.HasChange("droplet_id") { + if v, ok := d.GetOk("droplet_id"); ok { + log.Printf("[INFO] Assigning the reserved IP %s to the Droplet %d", d.Id(), v.(int)) + action, _, err := client.ReservedIPActions.Assign(context.Background(), d.Id(), v.(int)) + if err != nil { + return diag.Errorf( + "Error assigning reserved IP (%s) to the Droplet: %s", d.Id(), err) + } + + _, unassignedErr := waitForReservedIPReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) + if unassignedErr != nil { + return diag.Errorf( + "Error waiting for reserved IP (%s) to be Assigned: %s", d.Id(), unassignedErr) + } + } else { + log.Printf("[INFO] Unassigning the reserved IP %s", d.Id()) + action, _, err := client.ReservedIPActions.Unassign(context.Background(), d.Id()) + if err != nil { + return diag.Errorf( + "Error unassigning reserved IP (%s): %s", d.Id(), err) + } + + _, unassignedErr := waitForReservedIPReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) + if unassignedErr != nil { + return diag.Errorf( + "Error waiting for reserved IP (%s) to be Unassigned: %s", d.Id(), unassignedErr) + } + } + } + + return resourceDigitalOceanReservedIPRead(ctx, d, meta) +} + +func resourceDigitalOceanReservedIPRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*CombinedConfig).godoClient() + + log.Printf("[INFO] Reading the details of the reserved IP %s", d.Id()) + reservedIP, resp, err := client.ReservedIPs.Get(context.Background(), d.Id()) + if err != nil { + if resp != nil && resp.StatusCode == 404 { + log.Printf("[WARN] Reserved IP (%s) not found", d.Id()) + d.SetId("") + return nil + } + + return diag.Errorf("Error retrieving reserved IP: %s", err) + } + + if _, ok := d.GetOk("droplet_id"); ok && reservedIP.Droplet != nil { + d.Set("region", reservedIP.Droplet.Region.Slug) + d.Set("droplet_id", reservedIP.Droplet.ID) + } else { + d.Set("region", reservedIP.Region.Slug) + } + + d.Set("ip_address", reservedIP.IP) + d.Set("urn", reservedIP.URN()) + + return nil +} + +func resourceDigitalOceanReservedIPDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*CombinedConfig).godoClient() + + if _, ok := d.GetOk("droplet_id"); ok { + log.Printf("[INFO] Unassigning the reserved IP from the Droplet") + action, resp, err := client.ReservedIPActions.Unassign(context.Background(), d.Id()) + if resp.StatusCode != 422 { + if err != nil { + return diag.Errorf( + "Error unassigning reserved IP (%s) from the droplet: %s", d.Id(), err) + } + + _, unassignedErr := waitForReservedIPReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) + if unassignedErr != nil { + return diag.Errorf( + "Error waiting for reserved IP (%s) to be unassigned: %s", d.Id(), unassignedErr) + } + } else { + log.Printf("[DEBUG] Couldn't unassign reserved IP (%s) from droplet, possibly out of sync: %s", d.Id(), err) + } + } + + log.Printf("[INFO] Deleting reserved IP: %s", d.Id()) + _, err := client.ReservedIPs.Delete(context.Background(), d.Id()) + if err != nil { + return diag.Errorf("Error deleting reserved IP: %s", err) + } + + d.SetId("") + return nil +} + +func resourceDigitalOceanReservedIPImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + client := meta.(*CombinedConfig).godoClient() + reservedIP, resp, err := client.ReservedIPs.Get(context.Background(), d.Id()) + if resp.StatusCode != 404 { + if err != nil { + return nil, err + } + + d.Set("ip_address", reservedIP.IP) + d.Set("urn", reservedIP.URN()) + d.Set("region", reservedIP.Region.Slug) + + if reservedIP.Droplet != nil { + d.Set("droplet_id", reservedIP.Droplet.ID) + } + } + + return []*schema.ResourceData{d}, nil +} + +func waitForReservedIPReady( + ctx context.Context, d *schema.ResourceData, target string, pending []string, attribute string, meta interface{}, actionID int) (interface{}, error) { + log.Printf( + "[INFO] Waiting for reserved IP (%s) to have %s of %s", + d.Id(), attribute, target) + + stateConf := &resource.StateChangeConf{ + Pending: pending, + Target: []string{target}, + Refresh: newReservedIPStateRefreshFunc(d, attribute, meta, actionID), + Timeout: 60 * time.Minute, + Delay: 10 * time.Second, + MinTimeout: 3 * time.Second, + + NotFoundChecks: 60, + } + + return stateConf.WaitForStateContext(ctx) +} + +func newReservedIPStateRefreshFunc( + d *schema.ResourceData, attribute string, meta interface{}, actionID int) resource.StateRefreshFunc { + client := meta.(*CombinedConfig).godoClient() + return func() (interface{}, string, error) { + + log.Printf("[INFO] Assigning the reserved IP to the Droplet") + action, _, err := client.ReservedIPActions.Get(context.Background(), d.Id(), actionID) + if err != nil { + return nil, "", fmt.Errorf("Error retrieving reserved IP (%s) ActionId (%d): %s", d.Id(), actionID, err) + } + + log.Printf("[INFO] The reserved IP Action Status is %s", action.Status) + return &action, action.Status, nil + } +} diff --git a/digitalocean/resource_digitalocean_reserved_ip_assignment.go b/digitalocean/resource_digitalocean_reserved_ip_assignment.go new file mode 100644 index 000000000..d7f7600fa --- /dev/null +++ b/digitalocean/resource_digitalocean_reserved_ip_assignment.go @@ -0,0 +1,170 @@ +package digitalocean + +import ( + "context" + "errors" + "fmt" + "log" + "strconv" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceDigitalOceanReservedIPAssignment() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceDigitalOceanReservedIPAssignmentCreate, + ReadContext: resourceDigitalOceanReservedIPAssignmentRead, + DeleteContext: resourceDigitalOceanReservedIPAssignmentDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceDigitalOceanReservedIPAssignmentImport, + }, + + Schema: map[string]*schema.Schema{ + "ip_address": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.IsIPv4Address, + }, + "droplet_id": { + Type: schema.TypeInt, + Required: true, + ForceNew: true, + ValidateFunc: validation.NoZeroValues, + }, + }, + } +} + +func resourceDigitalOceanReservedIPAssignmentCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*CombinedConfig).godoClient() + + ipAddress := d.Get("ip_address").(string) + dropletID := d.Get("droplet_id").(int) + + log.Printf("[INFO] Assigning the reserved IP (%s) to the Droplet %d", ipAddress, dropletID) + action, _, err := client.ReservedIPActions.Assign(context.Background(), ipAddress, dropletID) + if err != nil { + return diag.Errorf( + "Error Assigning reserved IP (%s) to the droplet: %s", ipAddress, err) + } + + _, unassignedErr := waitForReservedIPAssignmentReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) + if unassignedErr != nil { + return diag.Errorf( + "Error waiting for reserved IP (%s) to be Assigned: %s", ipAddress, unassignedErr) + } + + d.SetId(resource.PrefixedUniqueId(fmt.Sprintf("%d-%s-", dropletID, ipAddress))) + return resourceDigitalOceanReservedIPAssignmentRead(ctx, d, meta) +} + +func resourceDigitalOceanReservedIPAssignmentRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*CombinedConfig).godoClient() + + ipAddress := d.Get("ip_address").(string) + dropletID := d.Get("droplet_id").(int) + + log.Printf("[INFO] Reading the details of the reserved IP %s", ipAddress) + reservedIP, _, err := client.ReservedIPs.Get(context.Background(), ipAddress) + if err != nil { + return diag.Errorf("Error retrieving reserved IP: %s", err) + } + + if reservedIP.Droplet == nil || reservedIP.Droplet.ID != dropletID { + log.Printf("[INFO] A Droplet was detected on the reserved IP.") + d.SetId("") + } + + return nil +} + +func resourceDigitalOceanReservedIPAssignmentDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*CombinedConfig).godoClient() + + ipAddress := d.Get("ip_address").(string) + dropletID := d.Get("droplet_id").(int) + + log.Printf("[INFO] Reading the details of the reserved IP %s", ipAddress) + reservedIP, _, err := client.ReservedIPs.Get(context.Background(), ipAddress) + if err != nil { + return diag.Errorf("Error retrieving reserved IP: %s", err) + } + + if reservedIP.Droplet.ID == dropletID { + log.Printf("[INFO] Unassigning the reserved IP from the Droplet") + action, _, err := client.ReservedIPActions.Unassign(context.Background(), ipAddress) + if err != nil { + return diag.Errorf("Error unassigning reserved IP (%s) from the droplet: %s", ipAddress, err) + } + + _, unassignedErr := waitForReservedIPAssignmentReady(ctx, d, "completed", []string{"new", "in-progress"}, "status", meta, action.ID) + if unassignedErr != nil { + return diag.Errorf( + "Error waiting for reserved IP (%s) to be unassigned: %s", ipAddress, unassignedErr) + } + } else { + log.Printf("[INFO] reserved IP already unassigned, removing from state.") + } + + d.SetId("") + return nil +} + +func waitForReservedIPAssignmentReady( + ctx context.Context, d *schema.ResourceData, target string, pending []string, attribute string, meta interface{}, actionID int) (interface{}, error) { + log.Printf( + "[INFO] Waiting for reserved IP (%s) to have %s of %s", + d.Get("ip_address").(string), attribute, target) + + stateConf := &resource.StateChangeConf{ + Pending: pending, + Target: []string{target}, + Refresh: newReservedIPAssignmentStateRefreshFunc(d, attribute, meta, actionID), + Timeout: 60 * time.Minute, + Delay: 10 * time.Second, + MinTimeout: 3 * time.Second, + + NotFoundChecks: 60, + } + + return stateConf.WaitForStateContext(ctx) +} + +func newReservedIPAssignmentStateRefreshFunc( + d *schema.ResourceData, attribute string, meta interface{}, actionID int) resource.StateRefreshFunc { + client := meta.(*CombinedConfig).godoClient() + return func() (interface{}, string, error) { + + log.Printf("[INFO] Refreshing the reserved IP state") + action, _, err := client.ReservedIPActions.Get(context.Background(), d.Get("ip_address").(string), actionID) + if err != nil { + return nil, "", fmt.Errorf("Error retrieving reserved IP (%s) ActionId (%d): %s", d.Get("ip_address").(string), actionID, err) + } + + log.Printf("[INFO] The reserved IP Action Status is %s", action.Status) + return &action, action.Status, nil + } +} + +func resourceDigitalOceanReservedIPAssignmentImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + if strings.Contains(d.Id(), ",") { + s := strings.Split(d.Id(), ",") + d.SetId(resource.PrefixedUniqueId(fmt.Sprintf("%s-%s-", s[1], s[0]))) + d.Set("ip_address", s[0]) + dropletID, err := strconv.Atoi(s[1]) + if err != nil { + return nil, err + } + d.Set("droplet_id", dropletID) + } else { + return nil, errors.New("must use the reserved IP and the ID of the Droplet joined with a comma (e.g. `ip_address,droplet_id`)") + } + + return []*schema.ResourceData{d}, nil +} diff --git a/digitalocean/resource_digitalocean_reserved_ip_assignment_test.go b/digitalocean/resource_digitalocean_reserved_ip_assignment_test.go new file mode 100644 index 000000000..08ab9fb75 --- /dev/null +++ b/digitalocean/resource_digitalocean_reserved_ip_assignment_test.go @@ -0,0 +1,225 @@ +package digitalocean + +import ( + "context" + "fmt" + "regexp" + "strconv" + "testing" + + "github.com/digitalocean/godo" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccDigitalOceanReservedIPAssignment(t *testing.T) { + var reservedIP godo.ReservedIP + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanReservedIPDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckDigitalOceanReservedIPAssignmentConfig, + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanReservedIPAttachmentExists("digitalocean_reserved_ip_assignment.foobar"), + resource.TestMatchResourceAttr( + "digitalocean_reserved_ip_assignment.foobar", "id", regexp.MustCompile("[0-9.]+")), + resource.TestMatchResourceAttr( + "digitalocean_reserved_ip_assignment.foobar", "droplet_id", regexp.MustCompile("[0-9]+")), + ), + }, + { + Config: testAccCheckDigitalOceanReservedIPAssignmentReassign, + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanReservedIPAttachmentExists("digitalocean_reserved_ip_assignment.foobar"), + resource.TestMatchResourceAttr( + "digitalocean_reserved_ip_assignment.foobar", "id", regexp.MustCompile("[0-9.]+")), + resource.TestMatchResourceAttr( + "digitalocean_reserved_ip_assignment.foobar", "droplet_id", regexp.MustCompile("[0-9]+")), + ), + }, + { + Config: testAccCheckDigitalOceanReservedIPAssignmentDeleteAssignment, + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanReservedIPExists("digitalocean_reserved_ip.foobar", &reservedIP), + resource.TestMatchResourceAttr( + "digitalocean_reserved_ip.foobar", "ip_address", regexp.MustCompile("[0-9.]+")), + ), + }, + }, + }) +} + +func TestAccDigitalOceanReservedIPAssignment_createBeforeDestroy(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanReservedIPDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckDigitalOceanReservedIPAssignmentConfig_createBeforeDestroy, + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanReservedIPAttachmentExists("digitalocean_reserved_ip_assignment.foobar"), + resource.TestMatchResourceAttr( + "digitalocean_reserved_ip_assignment.foobar", "id", regexp.MustCompile("[0-9.]+")), + resource.TestMatchResourceAttr( + "digitalocean_reserved_ip_assignment.foobar", "droplet_id", regexp.MustCompile("[0-9]+")), + ), + }, + { + Config: testAccCheckDigitalOceanReservedIPAssignmentConfig_createBeforeDestroyReassign, + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanReservedIPAttachmentExists("digitalocean_reserved_ip_assignment.foobar"), + resource.TestMatchResourceAttr( + "digitalocean_reserved_ip_assignment.foobar", "id", regexp.MustCompile("[0-9.]+")), + resource.TestMatchResourceAttr( + "digitalocean_reserved_ip_assignment.foobar", "droplet_id", regexp.MustCompile("[0-9]+")), + ), + }, + }, + }) +} + +func testAccCheckDigitalOceanReservedIPAttachmentExists(n string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.Attributes["ip_address"] == "" { + return fmt.Errorf("No floating IP is set") + } + fipID := rs.Primary.Attributes["ip_address"] + dropletID, err := strconv.Atoi(rs.Primary.Attributes["droplet_id"]) + if err != nil { + return err + } + + client := testAccProvider.Meta().(*CombinedConfig).godoClient() + + // Try to find the ReservedIP + foundReservedIP, _, err := client.ReservedIPs.Get(context.Background(), fipID) + if err != nil { + return err + } + + if foundReservedIP.IP != fipID || foundReservedIP.Droplet.ID != dropletID { + return fmt.Errorf("wrong floating IP attachment found") + } + + return nil + } +} + +var testAccCheckDigitalOceanReservedIPAssignmentConfig = ` +resource "digitalocean_reserved_ip" "foobar" { + region = "nyc3" +} + +resource "digitalocean_droplet" "foobar" { + count = 2 + name = "tf-acc-test-${count.index}" + size = "s-1vcpu-1gb" + image = "centos-7-x64" + region = "nyc3" + ipv6 = true + private_networking = true +} + +resource "digitalocean_reserved_ip_assignment" "foobar" { + ip_address = digitalocean_reserved_ip.foobar.ip_address + droplet_id = digitalocean_droplet.foobar.0.id +} +` + +var testAccCheckDigitalOceanReservedIPAssignmentReassign = ` +resource "digitalocean_reserved_ip" "foobar" { + region = "nyc3" +} + +resource "digitalocean_droplet" "foobar" { + count = 2 + name = "tf-acc-test-${count.index}" + size = "s-1vcpu-1gb" + image = "centos-7-x64" + region = "nyc3" + ipv6 = true + private_networking = true +} + +resource "digitalocean_reserved_ip_assignment" "foobar" { + ip_address = digitalocean_reserved_ip.foobar.ip_address + droplet_id = digitalocean_droplet.foobar.1.id +} +` + +var testAccCheckDigitalOceanReservedIPAssignmentDeleteAssignment = ` +resource "digitalocean_reserved_ip" "foobar" { + region = "nyc3" +} + +resource "digitalocean_droplet" "foobar" { + count = 2 + name = "tf-acc-test-${count.index}" + size = "s-1vcpu-1gb" + image = "centos-7-x64" + region = "nyc3" + ipv6 = true + private_networking = true +} +` + +var testAccCheckDigitalOceanReservedIPAssignmentConfig_createBeforeDestroy = ` +resource "digitalocean_droplet" "foobar" { + image = "centos-7-x64" + name = "tf-acc-test" + region = "nyc3" + size = "s-1vcpu-1gb" + + lifecycle { + create_before_destroy = true + } +} + +resource "digitalocean_reserved_ip" "foobar" { + region = "nyc3" +} + +resource "digitalocean_reserved_ip_assignment" "foobar" { + ip_address = digitalocean_reserved_ip.foobar.id + droplet_id = digitalocean_droplet.foobar.id + + lifecycle { + create_before_destroy = true + } +} +` + +var testAccCheckDigitalOceanReservedIPAssignmentConfig_createBeforeDestroyReassign = ` +resource "digitalocean_droplet" "foobar" { + image = "ubuntu-18-04-x64" + name = "tf-acc-test" + region = "nyc3" + size = "s-1vcpu-1gb" + + lifecycle { + create_before_destroy = true + } +} + +resource "digitalocean_reserved_ip" "foobar" { + region = "nyc3" +} + +resource "digitalocean_reserved_ip_assignment" "foobar" { + ip_address = digitalocean_reserved_ip.foobar.id + droplet_id = digitalocean_droplet.foobar.id + + lifecycle { + create_before_destroy = true + } +} +` diff --git a/digitalocean/resource_digitalocean_reserved_ip_test.go b/digitalocean/resource_digitalocean_reserved_ip_test.go new file mode 100644 index 000000000..09f79ad32 --- /dev/null +++ b/digitalocean/resource_digitalocean_reserved_ip_test.go @@ -0,0 +1,208 @@ +package digitalocean + +import ( + "context" + "fmt" + "regexp" + "testing" + + "github.com/digitalocean/godo" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func init() { + resource.AddTestSweepers("digitalocean_reserved_ip", &resource.Sweeper{ + Name: "digitalocean_reserved_ip", + F: testSweepReservedIPs, + }) + +} + +func testSweepReservedIPs(region string) error { + meta, err := sharedConfigForRegion(region) + if err != nil { + return err + } + + client := meta.(*CombinedConfig).godoClient() + + ips, _, err := client.ReservedIPs.List(context.Background(), nil) + if err != nil { + return err + } + + for _, ip := range ips { + if _, err := client.ReservedIPs.Delete(context.Background(), ip.IP); err != nil { + return err + } + } + + return nil +} + +func TestAccDigitalOceanReservedIP_Region(t *testing.T) { + var reservedIP godo.ReservedIP + + expectedURNRegEx, _ := regexp.Compile(`do:reservedip:(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$`) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanReservedIPDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckDigitalOceanReservedIPConfig_region, + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanReservedIPExists("digitalocean_reserved_ip.foobar", &reservedIP), + resource.TestCheckResourceAttr( + "digitalocean_reserved_ip.foobar", "region", "nyc3"), + resource.TestMatchResourceAttr("digitalocean_reserved_ip.foobar", "urn", expectedURNRegEx), + ), + }, + }, + }) +} + +func TestAccDigitalOceanReservedIP_Droplet(t *testing.T) { + var reservedIP godo.ReservedIP + rInt := acctest.RandInt() + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckDigitalOceanReservedIPDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckDigitalOceanReservedIPConfig_droplet(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanReservedIPExists("digitalocean_reserved_ip.foobar", &reservedIP), + resource.TestCheckResourceAttr( + "digitalocean_reserved_ip.foobar", "region", "nyc3"), + ), + }, + { + Config: testAccCheckDigitalOceanReservedIPConfig_Reassign(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanReservedIPExists("digitalocean_reserved_ip.foobar", &reservedIP), + resource.TestCheckResourceAttr( + "digitalocean_reserved_ip.foobar", "region", "nyc3"), + ), + }, + { + Config: testAccCheckDigitalOceanReservedIPConfig_Unassign(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckDigitalOceanReservedIPExists("digitalocean_reserved_ip.foobar", &reservedIP), + resource.TestCheckResourceAttr( + "digitalocean_reserved_ip.foobar", "region", "nyc3"), + ), + }, + }, + }) +} + +func testAccCheckDigitalOceanReservedIPDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*CombinedConfig).godoClient() + + for _, rs := range s.RootModule().Resources { + if rs.Type != "digitalocean_reserved_ip" { + continue + } + + // Try to find the key + _, _, err := client.ReservedIPs.Get(context.Background(), rs.Primary.ID) + + if err == nil { + return fmt.Errorf("Reserved IP still exists") + } + } + + return nil +} + +func testAccCheckDigitalOceanReservedIPExists(n string, reservedIP *godo.ReservedIP) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No Record ID is set") + } + + client := testAccProvider.Meta().(*CombinedConfig).godoClient() + + // Try to find the ReservedIP + foundReservedIP, _, err := client.ReservedIPs.Get(context.Background(), rs.Primary.ID) + + if err != nil { + return err + } + + if foundReservedIP.IP != rs.Primary.ID { + return fmt.Errorf("Record not found") + } + + *reservedIP = *foundReservedIP + + return nil + } +} + +var testAccCheckDigitalOceanReservedIPConfig_region = ` +resource "digitalocean_reserved_ip" "foobar" { + region = "nyc3" +}` + +func testAccCheckDigitalOceanReservedIPConfig_droplet(rInt int) string { + return fmt.Sprintf(` +resource "digitalocean_droplet" "foobar" { + name = "tf-acc-test-%d" + size = "s-1vcpu-1gb" + image = "centos-7-x64" + region = "nyc3" + ipv6 = true + private_networking = true +} + +resource "digitalocean_reserved_ip" "foobar" { + droplet_id = digitalocean_droplet.foobar.id + region = digitalocean_droplet.foobar.region +}`, rInt) +} + +func testAccCheckDigitalOceanReservedIPConfig_Reassign(rInt int) string { + return fmt.Sprintf(` +resource "digitalocean_droplet" "baz" { + name = "tf-acc-test-%d" + size = "s-1vcpu-1gb" + image = "centos-7-x64" + region = "nyc3" + ipv6 = true + private_networking = true +} + +resource "digitalocean_reserved_ip" "foobar" { + droplet_id = digitalocean_droplet.baz.id + region = digitalocean_droplet.baz.region +}`, rInt) +} + +func testAccCheckDigitalOceanReservedIPConfig_Unassign(rInt int) string { + return fmt.Sprintf(` +resource "digitalocean_droplet" "baz" { + name = "tf-acc-test-%d" + size = "s-1vcpu-1gb" + image = "centos-7-x64" + region = "nyc3" + ipv6 = true + private_networking = true +} + +resource "digitalocean_reserved_ip" "foobar" { + region = "nyc3" +}`, rInt) +} diff --git a/docs/data-sources/floating_ip.md b/docs/data-sources/floating_ip.md index afe80e171..30ed63690 100644 --- a/docs/data-sources/floating_ip.md +++ b/docs/data-sources/floating_ip.md @@ -4,6 +4,8 @@ page_title: "DigitalOcean: digitalocean_floating_ip" # digitalocean_floating_ip +~> **Deprecated:** DigitalOcean Floating IPs have been renamed reserved IPs. This data source will be removed in a future release. Please use `digitalocean_reserved_ip` instead. + Get information on a floating ip. This data source provides the region and Droplet id as configured on your DigitalOcean account. This is useful if the floating IP in question is not managed by Terraform or you need to find the Droplet the IP is diff --git a/docs/data-sources/reserved_ip.md b/docs/data-sources/reserved_ip.md new file mode 100644 index 000000000..0c3650468 --- /dev/null +++ b/docs/data-sources/reserved_ip.md @@ -0,0 +1,42 @@ +--- +page_title: "DigitalOcean: digitalocean_reserved_ip" +--- + +# digitalocean_reserved_ip + +Get information on a reserved IP. This data source provides the region and Droplet id +as configured on your DigitalOcean account. This is useful if the reserved IP +in question is not managed by Terraform or you need to find the Droplet the IP is +attached to. + +An error is triggered if the provided reserved IP does not exist. + +## Example Usage + +Get the reserved IP: + +```hcl +variable "public_ip" {} + +data "digitalocean_reserved_ip" "example" { + ip_address = var.public_ip +} + +output "fip_output" { + value = data.digitalocean_reserved_ip.example.droplet_id +} +``` + +## Argument Reference + +The following arguments are supported: + +* `ip_address` - (Required) The allocated IP address of the specific reserved IP to retrieve. + +## Attributes Reference + +The following attributes are exported: + +* `region`: The region that the reserved IP is reserved to. +* `urn`: The uniform resource name of the reserved IP. +* `droplet_id`: The Droplet id that the reserved IP has been assigned to. diff --git a/docs/resources/floating_ip.md b/docs/resources/floating_ip.md index bf7f16483..e33a0ed9d 100644 --- a/docs/resources/floating_ip.md +++ b/docs/resources/floating_ip.md @@ -4,6 +4,8 @@ page_title: "DigitalOcean: digitalocean_floating_ip" # digitalocean\_floating_ip +~> **Deprecated:** DigitalOcean Floating IPs have been renamed reserved IPs. This resource will be removed in a future release. Please use `digitalocean_reserved_ip` instead. + Provides a DigitalOcean Floating IP to represent a publicly-accessible static IP addresses that can be mapped to one of your Droplets. ~> **NOTE:** Floating IPs can be assigned to a Droplet either directly on the `digitalocean_floating_ip` resource by setting a `droplet_id` or using the `digitalocean_floating_ip_assignment` resource, but the two cannot be used together. diff --git a/docs/resources/floating_ip_assignment.md b/docs/resources/floating_ip_assignment.md index d10933f94..b6563a3a3 100644 --- a/docs/resources/floating_ip_assignment.md +++ b/docs/resources/floating_ip_assignment.md @@ -4,6 +4,8 @@ page_title: "DigitalOcean: digitalocean_floating_ip_assignment" # digitalocean\_floating_ip_assignment +~> **Deprecated:** DigitalOcean Floating IPs have been renamed reserved IPs. This resource will be removed in a future release. Please use `digitalocean_reserved_ip_assignment` instead. + Provides a resource for assigning an existing DigitalOcean Floating IP to a Droplet. This makes it easy to provision floating IP addresses that are not tied to the lifecycle of your Droplet. diff --git a/docs/resources/reserved_ip.md b/docs/resources/reserved_ip.md new file mode 100644 index 000000000..94ba47733 --- /dev/null +++ b/docs/resources/reserved_ip.md @@ -0,0 +1,49 @@ +--- +page_title: "DigitalOcean: digitalocean_reserved_ip" +--- + +# digitalocean\_reserved_ip + +Provides a DigitalOcean reserved IP to represent a publicly-accessible static IP addresses that can be mapped to one of your Droplets. + +~> **NOTE:** Reserved IPs can be assigned to a Droplet either directly on the `digitalocean_reserved_ip` resource by setting a `droplet_id` or using the `digitalocean_reserved_ip_assignment` resource, but the two cannot be used together. + +## Example Usage + +```hcl +resource "digitalocean_droplet" "example" { + name = "example" + size = "s-1vcpu-1gb" + image = "ubuntu-22-04-x64" + region = "nyc3" + ipv6 = true + private_networking = true +} + +resource "digitalocean_reserved_ip" "example" { + droplet_id = digitalocean_droplet.example.id + region = digitalocean_droplet.example.region +} +``` + +## Argument Reference + +The following arguments are supported: + +* `region` - (Required) The region that the reserved IP is reserved to. +* `droplet_id` - (Optional) The ID of Droplet that the reserved IP will be assigned to. + +## Attributes Reference + +The following attributes are exported: + +* `ip_address` - The IP Address of the resource +* `urn` - The uniform resource name of the reserved ip + +## Import + +Reserved IPs can be imported using the `ip`, e.g. + +``` +terraform import digitalocean_reserved_ip.myip 192.168.0.1 +``` diff --git a/docs/resources/reserved_ip_assignment.md b/docs/resources/reserved_ip_assignment.md new file mode 100644 index 000000000..2978634a3 --- /dev/null +++ b/docs/resources/reserved_ip_assignment.md @@ -0,0 +1,47 @@ +--- +page_title: "DigitalOcean: digitalocean_reserved_ip_assignment" +--- + +# digitalocean\_reserved_ip_assignment + +Provides a resource for assigning an existing DigitalOcean reserved IP to a Droplet. This +makes it easy to provision reserved IP addresses that are not tied to the lifecycle of your +Droplet. + +## Example Usage + +```hcl +resource "digitalocean_reserved_ip" "example" { + region = "nyc3" +} + +resource "digitalocean_droplet" "example" { + name = "baz" + size = "s-1vcpu-1gb" + image = "ubuntu-22-04-x64" + region = "nyc3" + ipv6 = true + private_networking = true +} + +resource "digitalocean_reserved_ip_assignment" "example" { + ip_address = digitalocean_reserved_ip.example.ip_address + droplet_id = digitalocean_droplet.example.id +} +``` + +## Argument Reference + +The following arguments are supported: + +* `ip_address` - (Required) The reserved IP to assign to the Droplet. +* `droplet_id` - (Optional) The ID of Droplet that the reserved IP will be assigned to. + +## Import + +Reserved IP assignments can be imported using the reserved IP itself and the `id` of +the Droplet joined with a comma. For example: + +``` +terraform import digitalocean_reserved_ip_assignment.foobar 192.0.2.1,123456 +``` diff --git a/go.mod b/go.mod index 5efdfbe27..d474c0469 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/digitalocean/terraform-provider-digitalocean require ( github.com/aws/aws-sdk-go v1.42.18 - github.com/digitalocean/godo v1.79.0 + github.com/digitalocean/godo v1.81.0 github.com/hashicorp/awspolicyequivalence v1.5.0 github.com/hashicorp/go-uuid v1.0.2 github.com/hashicorp/go-version v1.3.0 diff --git a/go.sum b/go.sum index 8cff40003..4108717ea 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/digitalocean/godo v1.79.0 h1:Uwef1rh/QLnADR1pR6oyZfqA5zLqondiSnWFPwDzbog= -github.com/digitalocean/godo v1.79.0/go.mod h1:BPCqvwbjbGqxuUnIKB4EvS/AX7IDnNmt5fwvIkWo+ew= +github.com/digitalocean/godo v1.81.0 h1:sjb3fOfPfSlUQUK22E87BcI8Zx2qtnF7VUCCO4UK3C8= +github.com/digitalocean/godo v1.81.0/go.mod h1:BPCqvwbjbGqxuUnIKB4EvS/AX7IDnNmt5fwvIkWo+ew= github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= diff --git a/vendor/github.com/digitalocean/godo/CHANGELOG.md b/vendor/github.com/digitalocean/godo/CHANGELOG.md index a199843dd..5dfd0a263 100644 --- a/vendor/github.com/digitalocean/godo/CHANGELOG.md +++ b/vendor/github.com/digitalocean/godo/CHANGELOG.md @@ -1,5 +1,17 @@ # Change Log +## [v1.81.0] - 2022-06-15 + +- #532 - @senorprogrammer - Add support for Reserved IP addresses +- #538 - @bentranter - util: update droplet create example +- #537 - @rpmoore - Adding project_id to databases +- #536 - @andrewsomething - account: Now may include info on current team. +- #535 - @ElanHasson - APPS-5636 Update App Platform for functions and Starter Tier App Proposals. + +## [v1.80.0] - 2022-05-23 + +- #533 - @ElanHasson - APPS-5636 - App Platform updates + ## [v1.79.0] - 2022-04-29 - #530 - @anitgandhi - monitoring: alerts for Load Balancers TLS conns/s utilization diff --git a/vendor/github.com/digitalocean/godo/account.go b/vendor/github.com/digitalocean/godo/account.go index a6691e84a..48582c9ee 100644 --- a/vendor/github.com/digitalocean/godo/account.go +++ b/vendor/github.com/digitalocean/godo/account.go @@ -22,14 +22,22 @@ var _ AccountService = &AccountServiceOp{} // Account represents a DigitalOcean Account type Account struct { - DropletLimit int `json:"droplet_limit,omitempty"` - FloatingIPLimit int `json:"floating_ip_limit,omitempty"` - VolumeLimit int `json:"volume_limit,omitempty"` - Email string `json:"email,omitempty"` - UUID string `json:"uuid,omitempty"` - EmailVerified bool `json:"email_verified,omitempty"` - Status string `json:"status,omitempty"` - StatusMessage string `json:"status_message,omitempty"` + DropletLimit int `json:"droplet_limit,omitempty"` + FloatingIPLimit int `json:"floating_ip_limit,omitempty"` + ReservedIPLimit int `json:"reserved_ip_limit,omitempty"` + VolumeLimit int `json:"volume_limit,omitempty"` + Email string `json:"email,omitempty"` + UUID string `json:"uuid,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` + Status string `json:"status,omitempty"` + StatusMessage string `json:"status_message,omitempty"` + Team *TeamInfo `json:"team,omitempty"` +} + +// TeamInfo contains information about the currently team context. +type TeamInfo struct { + Name string `json:"name,omitempty"` + UUID string `json:"uuid,omitempty"` } type accountRoot struct { diff --git a/vendor/github.com/digitalocean/godo/apps.gen.go b/vendor/github.com/digitalocean/godo/apps.gen.go index bfc1fa441..1daa84e8c 100644 --- a/vendor/github.com/digitalocean/godo/apps.gen.go +++ b/vendor/github.com/digitalocean/godo/apps.gen.go @@ -117,7 +117,7 @@ const ( AppAlertSpecOperator_LessThan AppAlertSpecOperator = "LESS_THAN" ) -// AppAlertSpecRule - CPU_UTILIZATION: Represents CPU for a given container instance. Only applicable at the component level. - MEM_UTILIZATION: Represents RAM for a given container instance. Only applicable at the component level. - RESTART_COUNT: Represents restart count for a given container instance. Only applicable at the component level. - DEPLOYMENT_FAILED: Represents whether a deployment has failed. Only applicable at the app level. - DEPLOYMENT_LIVE: Represents whether a deployment has succeeded. Only applicable at the app level. - DOMAIN_FAILED: Represents whether a domain configuration has failed. Only applicable at the app level. - DOMAIN_LIVE: Represents whether a domain configuration has succeeded. Only applicable at the app level. - FUNCTIONS_ACTIVATION_COUNT: Represents an activation count for a given functions instance. Only applicable to functions components. - FUNCTIONS_AVERAGE_DURATION_MS: Represents the average duration for function runtimes. Only applicable to functions components. - FUNCTIONS_ERROR_RATE_PER_MINUTE: Represents an error rate per minute for a given functions instance. Only applicable to functions components. - FUNCTIONS_AVERAGE_WAIT_TIME_MS: Represents the average wait time for functions. Only applicable to functions components. - FUNCTIONS_ERROR_COUNT: Represents an error count for a given functions instance. Only applicable to functions components. +// AppAlertSpecRule - CPU_UTILIZATION: Represents CPU for a given container instance. Only applicable at the component level. - MEM_UTILIZATION: Represents RAM for a given container instance. Only applicable at the component level. - RESTART_COUNT: Represents restart count for a given container instance. Only applicable at the component level. - DEPLOYMENT_FAILED: Represents whether a deployment has failed. Only applicable at the app level. - DEPLOYMENT_LIVE: Represents whether a deployment has succeeded. Only applicable at the app level. - DOMAIN_FAILED: Represents whether a domain configuration has failed. Only applicable at the app level. - DOMAIN_LIVE: Represents whether a domain configuration has succeeded. Only applicable at the app level. - FUNCTIONS_ACTIVATION_COUNT: Represents an activation count for a given functions instance. Only applicable to functions components. - FUNCTIONS_AVERAGE_DURATION_MS: Represents the average duration for function runtimes. Only applicable to functions components. - FUNCTIONS_ERROR_RATE_PER_MINUTE: Represents an error rate per minute for a given functions instance. Only applicable to functions components. - FUNCTIONS_AVERAGE_WAIT_TIME_MS: Represents the average wait time for functions. Only applicable to functions components. - FUNCTIONS_ERROR_COUNT: Represents an error count for a given functions instance. Only applicable to functions components. - FUNCTIONS_GB_RATE_PER_SECOND: Represents the rate of memory consumption (GB x seconds) for functions. Only applicable to functions components. type AppAlertSpecRule string // List of AppAlertSpecRule @@ -135,6 +135,7 @@ const ( AppAlertSpecRule_FunctionsErrorRatePerMinute AppAlertSpecRule = "FUNCTIONS_ERROR_RATE_PER_MINUTE" AppAlertSpecRule_FunctionsAverageWaitTimeMs AppAlertSpecRule = "FUNCTIONS_AVERAGE_WAIT_TIME_MS" AppAlertSpecRule_FunctionsErrorCount AppAlertSpecRule = "FUNCTIONS_ERROR_COUNT" + AppAlertSpecRule_FunctionsGBRatePerSecond AppAlertSpecRule = "FUNCTIONS_GB_RATE_PER_SECOND" ) // AppAlertSpecWindow the model 'AppAlertSpecWindow' @@ -189,7 +190,7 @@ type AppDomainSpec struct { // Optional. If the domain uses DigitalOcean DNS and you would like App Platform to automatically manage it for you, set this to the name of the domain on your account. For example, If the domain you are adding is `app.domain.com`, the zone could be `domain.com`. Zone string `json:"zone,omitempty"` Certificate string `json:"certificate,omitempty"` - // Optional. The minimum version of TLS a client application can use to access resources for the domain. Must be one of the following values wrapped within quotations: `\"1.0\"`, `\"1.1\"`, `\"1.2\"`, or `\"1.3\"`. + // Optional. The minimum version of TLS a client application can use to access resources for the domain. Must be one of the following values wrapped within quotations: `\"1.2\"` or `\"1.3\"`. MinimumTLSVersion string `json:"minimum_tls_version,omitempty"` } @@ -323,7 +324,7 @@ type AppLogDestinationSpecPapertrail struct { type AppRouteSpec struct { // An HTTP path prefix. Paths must start with / and must be unique across all components within an app. Path string `json:"path,omitempty"` - // An optional flag to preserve the path that is forwarded to the backend service. By default, the HTTP request path will be trimmed from the left when forwarded to the component. For example, a component with `path=/api` will have requests to `/api/list` trimmed to `/list`. If this value is `true`, the path will remain `/api/list`. + // An optional flag to preserve the path that is forwarded to the backend service. By default, the HTTP request path will be trimmed from the left when forwarded to the component. For example, a component with `path=/api` will have requests to `/api/list` trimmed to `/list`. If this value is `true`, the path will remain `/api/list`. Note: this is not applicable for Functions Components. PreservePathPrefix bool `json:"preserve_path_prefix,omitempty"` } @@ -387,14 +388,15 @@ type AppServiceSpecHealthCheck struct { type AppSpec struct { // The name of the app. Must be unique across all apps in the same account. Name string `json:"name"` - // Workloads which expose publicy-accessible HTTP services. + // Workloads which expose publicly-accessible HTTP services. Services []*AppServiceSpec `json:"services,omitempty"` // Content which can be rendered to static web assets. StaticSites []*AppStaticSiteSpec `json:"static_sites,omitempty"` // Workloads which do not expose publicly-accessible HTTP services. Workers []*AppWorkerSpec `json:"workers,omitempty"` // Pre and post deployment workloads which do not expose publicly-accessible HTTP routes. - Jobs []*AppJobSpec `json:"jobs,omitempty"` + Jobs []*AppJobSpec `json:"jobs,omitempty"` + // Workloads which expose publicly-accessible HTTP services via Functions Components. Functions []*AppFunctionsSpec `json:"functions,omitempty"` // Database instances which can provide persistence to workloads within the application. Databases []*AppDatabaseSpec `json:"databases,omitempty"` @@ -510,7 +512,7 @@ type AppCORSPolicy struct { AllowHeaders []string `json:"allow_headers,omitempty"` // The set of HTTP response headers that browsers are allowed to access. This configures the Access-Control-Expose-Headers header. ExposeHeaders []string `json:"expose_headers,omitempty"` - // An optional duration specifiying how long browsers can cache the results of a preflight request. This configures the Access-Control-Max-Age header. Example: `5h30m`. + // An optional duration specifying how long browsers can cache the results of a preflight request. This configures the Access-Control-Max-Age header. Example: `5h30m`. MaxAge string `json:"max_age,omitempty"` // Whether browsers should expose the response to the client-side JavaScript code when the request's credentials mode is `include`. This configures the Access-Control-Allow-Credentials header. AllowCredentials bool `json:"allow_credentials,omitempty"` @@ -803,20 +805,29 @@ type AppProposeRequest struct { // AppProposeResponse struct for AppProposeResponse type AppProposeResponse struct { - AppIsStatic bool `json:"app_is_static,omitempty"` + // Deprecated. Please use AppIsStarter instead. + AppIsStatic bool `json:"app_is_static,omitempty"` + // Indicates whether the app name is available. AppNameAvailable bool `json:"app_name_available,omitempty"` // If the app name is unavailable, this will be set to a suggested available name. AppNameSuggestion string `json:"app_name_suggestion,omitempty"` - // The number of existing static apps the account has. + // Deprecated. Please use ExistingStarterApps instead. ExistingStaticApps string `json:"existing_static_apps,omitempty"` - // The maximum number of free static apps the account can have. Any additional static apps will be charged for. + // Deprecated. Please use MaxFreeStarterApps instead. MaxFreeStaticApps string `json:"max_free_static_apps,omitempty"` Spec *AppSpec `json:"spec,omitempty"` - AppCost float32 `json:"app_cost,omitempty"` - // The monthly cost of the proposed app in USD using the next pricing plan tier. For example, if you propose an app that uses the Basic tier, the `app_tier_upgrade_cost` field displays the monthly cost of the app if it were to use the Professional tier. If the proposed app already uses the most expensive tier, the field is empty. + // The monthly cost of the proposed app in USD. + AppCost float32 `json:"app_cost,omitempty"` + // The monthly cost of the proposed app in USD using the next pricing plan tier. For example, if you propose an app that uses the Basic tier, the `AppTierUpgradeCost` field displays the monthly cost of the app if it were to use the Professional tier. If the proposed app already uses the most expensive tier, the field is empty. AppTierUpgradeCost float32 `json:"app_tier_upgrade_cost,omitempty"` - // The monthly cost of the proposed app in USD using the previous pricing plan tier. For example, if you propose an app that uses the Professional tier, the `app_tier_downgrade_cost` field displays the monthly cost of the app if it were to use the Basic tier. If the proposed app already uses the lest expensive tier, the field is empty. + // The monthly cost of the proposed app in USD using the previous pricing plan tier. For example, if you propose an app that uses the Professional tier, the `AppTierDowngradeCost` field displays the monthly cost of the app if it were to use the Basic tier. If the proposed app already uses the lest expensive tier, the field is empty. AppTierDowngradeCost float32 `json:"app_tier_downgrade_cost,omitempty"` + // The number of existing starter tier apps the account has. + ExistingStarterApps string `json:"existing_starter_apps,omitempty"` + // The maximum number of free starter apps the account can have. Any additional starter apps will be charged for. These include apps with only static sites, functions, and databases. + MaxFreeStarterApps string `json:"max_free_starter_apps,omitempty"` + // Indicates whether the app is a starter tier app. + AppIsStarter bool `json:"app_is_starter,omitempty"` } // AppRegion struct for AppRegion diff --git a/vendor/github.com/digitalocean/godo/databases.go b/vendor/github.com/digitalocean/godo/databases.go index 6d0adbded..a3f5e8d44 100644 --- a/vendor/github.com/digitalocean/godo/databases.go +++ b/vendor/github.com/digitalocean/godo/databases.go @@ -154,6 +154,7 @@ type Database struct { CreatedAt time.Time `json:"created_at,omitempty"` PrivateNetworkUUID string `json:"private_network_uuid,omitempty"` Tags []string `json:"tags,omitempty"` + ProjectID string `json:"project_id,omitempty"` } // DatabaseCA represents a database ca. @@ -217,6 +218,7 @@ type DatabaseCreateRequest struct { PrivateNetworkUUID string `json:"private_network_uuid"` Tags []string `json:"tags,omitempty"` BackupRestore *DatabaseBackupRestore `json:"backup_restore,omitempty"` + ProjectID string `json:"project_id"` } // DatabaseResizeRequest can be used to initiate a database resize operation. diff --git a/vendor/github.com/digitalocean/godo/godo.go b/vendor/github.com/digitalocean/godo/godo.go index 6b76f92b9..1e73fb875 100644 --- a/vendor/github.com/digitalocean/godo/godo.go +++ b/vendor/github.com/digitalocean/godo/godo.go @@ -20,7 +20,7 @@ import ( ) const ( - libraryVersion = "1.79.0" + libraryVersion = "1.81.0" defaultBaseURL = "https://api.digitalocean.com/" userAgent = "godo/" + libraryVersion mediaType = "application/json" @@ -64,6 +64,8 @@ type Client struct { Sizes SizesService FloatingIPs FloatingIPsService FloatingIPActions FloatingIPActionsService + ReservedIPs ReservedIPsService + ReservedIPActions ReservedIPActionsService Snapshots SnapshotsService Storage StorageService StorageActions StorageActionsService @@ -219,6 +221,8 @@ func NewClient(httpClient *http.Client) *Client { c.Firewalls = &FirewallsServiceOp{client: c} c.FloatingIPs = &FloatingIPsServiceOp{client: c} c.FloatingIPActions = &FloatingIPActionsServiceOp{client: c} + c.ReservedIPs = &ReservedIPsServiceOp{client: c} + c.ReservedIPActions = &ReservedIPActionsServiceOp{client: c} c.Images = &ImagesServiceOp{client: c} c.ImageActions = &ImageActionsServiceOp{client: c} c.Invoices = &InvoicesServiceOp{client: c} diff --git a/vendor/github.com/digitalocean/godo/reserved_ips.go b/vendor/github.com/digitalocean/godo/reserved_ips.go new file mode 100644 index 000000000..f767f86c0 --- /dev/null +++ b/vendor/github.com/digitalocean/godo/reserved_ips.go @@ -0,0 +1,145 @@ +package godo + +import ( + "context" + "fmt" + "net/http" +) + +const resourceType = "ReservedIP" +const reservedIPsBasePath = "v2/reserved_ips" + +// ReservedIPsService is an interface for interfacing with the reserved IPs +// endpoints of the Digital Ocean API. +// See: https://docs.digitalocean.com/reference/api/api-reference/#tag/Reserved-IPs +type ReservedIPsService interface { + List(context.Context, *ListOptions) ([]ReservedIP, *Response, error) + Get(context.Context, string) (*ReservedIP, *Response, error) + Create(context.Context, *ReservedIPCreateRequest) (*ReservedIP, *Response, error) + Delete(context.Context, string) (*Response, error) +} + +// ReservedIPsServiceOp handles communication with the reserved IPs related methods of the +// DigitalOcean API. +type ReservedIPsServiceOp struct { + client *Client +} + +var _ ReservedIPsService = &ReservedIPsServiceOp{} + +// ReservedIP represents a Digital Ocean reserved IP. +type ReservedIP struct { + Region *Region `json:"region"` + Droplet *Droplet `json:"droplet"` + IP string `json:"ip"` +} + +func (f ReservedIP) String() string { + return Stringify(f) +} + +// URN returns the reserved IP in a valid DO API URN form. +func (f ReservedIP) URN() string { + return ToURN(resourceType, f.IP) +} + +type reservedIPsRoot struct { + ReservedIPs []ReservedIP `json:"reserved_ips"` + Links *Links `json:"links"` + Meta *Meta `json:"meta"` +} + +type reservedIPRoot struct { + ReservedIP *ReservedIP `json:"reserved_ip"` + Links *Links `json:"links,omitempty"` +} + +// ReservedIPCreateRequest represents a request to create a reserved IP. +// Specify DropletID to assign the reserved IP to a Droplet or Region +// to reserve it to the region. +type ReservedIPCreateRequest struct { + Region string `json:"region,omitempty"` + DropletID int `json:"droplet_id,omitempty"` +} + +// List all reserved IPs. +func (r *ReservedIPsServiceOp) List(ctx context.Context, opt *ListOptions) ([]ReservedIP, *Response, error) { + path := reservedIPsBasePath + path, err := addOptions(path, opt) + if err != nil { + return nil, nil, err + } + + req, err := r.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(reservedIPsRoot) + resp, err := r.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + if l := root.Links; l != nil { + resp.Links = l + } + if m := root.Meta; m != nil { + resp.Meta = m + } + + return root.ReservedIPs, resp, err +} + +// Get an individual reserved IP. +func (r *ReservedIPsServiceOp) Get(ctx context.Context, ip string) (*ReservedIP, *Response, error) { + path := fmt.Sprintf("%s/%s", reservedIPsBasePath, ip) + + req, err := r.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(reservedIPRoot) + resp, err := r.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.ReservedIP, resp, err +} + +// Create a reserved IP. If the DropletID field of the request is not empty, +// the reserved IP will also be assigned to the droplet. +func (r *ReservedIPsServiceOp) Create(ctx context.Context, createRequest *ReservedIPCreateRequest) (*ReservedIP, *Response, error) { + path := reservedIPsBasePath + + req, err := r.client.NewRequest(ctx, http.MethodPost, path, createRequest) + if err != nil { + return nil, nil, err + } + + root := new(reservedIPRoot) + resp, err := r.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + if l := root.Links; l != nil { + resp.Links = l + } + + return root.ReservedIP, resp, err +} + +// Delete a reserved IP. +func (r *ReservedIPsServiceOp) Delete(ctx context.Context, ip string) (*Response, error) { + path := fmt.Sprintf("%s/%s", reservedIPsBasePath, ip) + + req, err := r.client.NewRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return nil, err + } + + resp, err := r.client.Do(ctx, req, nil) + + return resp, err +} diff --git a/vendor/github.com/digitalocean/godo/reserved_ips_actions.go b/vendor/github.com/digitalocean/godo/reserved_ips_actions.go new file mode 100644 index 000000000..8a9e2408c --- /dev/null +++ b/vendor/github.com/digitalocean/godo/reserved_ips_actions.go @@ -0,0 +1,109 @@ +package godo + +import ( + "context" + "fmt" + "net/http" +) + +// ReservedIPActionsService is an interface for interfacing with the +// reserved IPs actions endpoints of the Digital Ocean API. +// See: https://docs.digitalocean.com/reference/api/api-reference/#tag/Reserved-IP-Actions +type ReservedIPActionsService interface { + Assign(ctx context.Context, ip string, dropletID int) (*Action, *Response, error) + Unassign(ctx context.Context, ip string) (*Action, *Response, error) + Get(ctx context.Context, ip string, actionID int) (*Action, *Response, error) + List(ctx context.Context, ip string, opt *ListOptions) ([]Action, *Response, error) +} + +// ReservedIPActionsServiceOp handles communication with the reserved IPs +// action related methods of the DigitalOcean API. +type ReservedIPActionsServiceOp struct { + client *Client +} + +// Assign a reserved IP to a droplet. +func (s *ReservedIPActionsServiceOp) Assign(ctx context.Context, ip string, dropletID int) (*Action, *Response, error) { + request := &ActionRequest{ + "type": "assign", + "droplet_id": dropletID, + } + return s.doAction(ctx, ip, request) +} + +// Unassign a rerserved IP from the droplet it is currently assigned to. +func (s *ReservedIPActionsServiceOp) Unassign(ctx context.Context, ip string) (*Action, *Response, error) { + request := &ActionRequest{"type": "unassign"} + return s.doAction(ctx, ip, request) +} + +// Get an action for a particular reserved IP by id. +func (s *ReservedIPActionsServiceOp) Get(ctx context.Context, ip string, actionID int) (*Action, *Response, error) { + path := fmt.Sprintf("%s/%d", reservedIPActionPath(ip), actionID) + return s.get(ctx, path) +} + +// List the actions for a particular reserved IP. +func (s *ReservedIPActionsServiceOp) List(ctx context.Context, ip string, opt *ListOptions) ([]Action, *Response, error) { + path := reservedIPActionPath(ip) + path, err := addOptions(path, opt) + if err != nil { + return nil, nil, err + } + + return s.list(ctx, path) +} + +func (s *ReservedIPActionsServiceOp) doAction(ctx context.Context, ip string, request *ActionRequest) (*Action, *Response, error) { + path := reservedIPActionPath(ip) + + req, err := s.client.NewRequest(ctx, http.MethodPost, path, request) + if err != nil { + return nil, nil, err + } + + root := new(actionRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Event, resp, err +} + +func (s *ReservedIPActionsServiceOp) get(ctx context.Context, path string) (*Action, *Response, error) { + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(actionRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.Event, resp, err +} + +func (s *ReservedIPActionsServiceOp) list(ctx context.Context, path string) ([]Action, *Response, error) { + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(actionsRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + if l := root.Links; l != nil { + resp.Links = l + } + + return root.Actions, resp, err +} + +func reservedIPActionPath(ip string) string { + return fmt.Sprintf("%s/%s/actions", reservedIPsBasePath, ip) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 1456eeb80..6c2efe198 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -64,7 +64,7 @@ github.com/aws/aws-sdk-go/service/sts/stsiface # github.com/davecgh/go-spew v1.1.1 ## explicit github.com/davecgh/go-spew/spew -# github.com/digitalocean/godo v1.79.0 +# github.com/digitalocean/godo v1.81.0 ## explicit; go 1.18 github.com/digitalocean/godo github.com/digitalocean/godo/metrics