From f53c0341b66e6ab7a39f770e5836253a32fba757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Sch=C3=A4fer?= Date: Wed, 5 Jun 2024 07:31:56 +0200 Subject: [PATCH] SYSENG-1335: reserve available addresses --- CHANGELOG.md | 3 + anxcloud/data_source_ip_address.go | 71 ++++++-- anxcloud/provider.go | 10 ++ anxcloud/resource_ip_address.go | 154 +++++++++++++++++- anxcloud/resource_ip_address_test.go | 134 +++++++++++++++ anxcloud/schema_ip_address.go | 58 ------- docs/resources/ip_address.md | 46 ++++-- .../resources/anxcloud_ip_address/resource.tf | 30 +++- go.mod | 2 +- go.sum | 4 +- 10 files changed, 413 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a846e705..0f13c779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ If the change isn't user-facing but still relevant enough for a changelog entry, * (internal)? scope: short description (#pr, @author) --> +### Changed +* resource/anxcloud_ip_address: reserve an available address based on filters (#163, @anx-mschaefer) + ## [0.6.2] - 2024-05-28 ### Added diff --git a/anxcloud/data_source_ip_address.go b/anxcloud/data_source_ip_address.go index ae825835..ead0bd0d 100644 --- a/anxcloud/data_source_ip_address.go +++ b/anxcloud/data_source_ip_address.go @@ -20,21 +20,62 @@ Retrieves an IP address. - When using the address argument, only IP addresses unique to the scope of your access token for Anexia Cloud can be retrieved. You can however get a unique result by specifying the related VLAN or network prefix. `, ReadContext: dataSourceIPAddressRead, - Schema: schemaWith(schemaIPAddress(), - fieldsExactlyOneOf("id", "address"), - fieldsOptional( - "vlan_id", - "network_prefix_id", - ), - fieldsComputed( - "description_customer", - "description_internal", - "role", - "version", - "status", - "organization", - ), - ), + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: identifierDescription, + ExactlyOneOf: []string{"id", "address"}, + }, + "network_prefix_id": { + Type: schema.TypeString, + Optional: true, + Description: "Identifier of the related network prefix.", + }, + "address": { + Type: schema.TypeString, + Optional: true, + Description: "IP address.", + }, + "vlan_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "The associated VLAN identifier.", + }, + + "description_customer": { + Type: schema.TypeString, + Computed: true, + Description: "Additional customer description.", + }, + "description_internal": { + Type: schema.TypeString, + Computed: true, + Description: "Internal description.", + }, + "role": { + Type: schema.TypeString, + Computed: true, + Description: "Role of the IP address", + }, + "organization": { + Type: schema.TypeString, + Computed: true, + Description: "Customer of yours. Reseller only.", + }, + "version": { + Type: schema.TypeInt, + Computed: true, + Description: "IP version.", + }, + "status": { + Type: schema.TypeString, + Computed: true, + Description: "Status of the IP address", + }, + }, } } diff --git a/anxcloud/provider.go b/anxcloud/provider.go index 4170d155..031253b8 100644 --- a/anxcloud/provider.go +++ b/anxcloud/provider.go @@ -120,6 +120,16 @@ func handleNotFoundError(err error) error { return err } +// isLegacyNotFoundError returns true, if the provided [err] +// is a "Not Found" status error returned by the legacy api client +func isLegacyNotFoundError(err error) bool { + var respErr *client.ResponseError + if errors.As(err, &respErr) && respErr.ErrorData.Code == http.StatusNotFound { + return true + } + return false +} + func apiFromProviderConfig(m interface{}) api.API { return m.(providerContext).api } diff --git a/anxcloud/resource_ip_address.go b/anxcloud/resource_ip_address.go index 94e8e500..eb65a99a 100644 --- a/anxcloud/resource_ip_address.go +++ b/anxcloud/resource_ip_address.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "go.anx.io/go-anxcloud/pkg/ipam/address" + "go.anx.io/go-anxcloud/pkg/vlan" ) const ( @@ -19,7 +20,8 @@ const ( func resourceIPAddress() *schema.Resource { return &schema.Resource{ - Description: "This resource allows you to create and configure IP addresses.", + Description: "This resource allows you to create and configure IP addresses. " + + "Addresses created without the `address` attribute will expire if the reservation period exceeds before assigned to a VM.", CreateContext: tagsMiddlewareCreate(resourceIPAddressCreate), ReadContext: tagsMiddlewareRead(resourceIPAddressRead), UpdateContext: tagsMiddlewareUpdate(resourceIPAddressUpdate), @@ -33,18 +35,144 @@ func resourceIPAddress() *schema.Resource { Update: schema.DefaultTimeout(10 * time.Minute), Delete: schema.DefaultTimeout(5 * time.Minute), }, - Schema: withTagsAttribute(schemaIPAddress()), + Schema: withTagsAttribute( + map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Computed: true, + Description: identifierDescription, + }, + "address": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + Description: "IP address.", + ExactlyOneOf: []string{"address", "vlan_id"}, + RequiredWith: []string{"network_prefix_id"}, // network_prefix_id is required if address is set + }, + "vlan_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + Description: "The associated VLAN identifier.", + ExactlyOneOf: []string{"address", "vlan_id"}, + }, + "network_prefix_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + Description: "Identifier of the related network prefix.", + }, + "version": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + ForceNew: true, + Description: "IP version.", + }, + "description_customer": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Additional customer description.", + ConflictsWith: []string{"vlan_id"}, + }, + "description_internal": { + Type: schema.TypeString, + Computed: true, + Description: "Internal description.", + }, + "role": { + Type: schema.TypeString, + Optional: true, + Default: "Default", + Description: "Role of the IP address", + ConflictsWith: []string{"vlan_id"}, + }, + "organization": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Customer of yours. Reseller only.", + ConflictsWith: []string{"vlan_id"}, + }, + "status": { + Type: schema.TypeString, + Computed: true, + Description: "Status of the IP address", + }, + "reservation_period_seconds": { + Type: schema.TypeInt, + Optional: true, + ConflictsWith: []string{"address"}, + Description: "Period for the requested reservation in seconds. Defaults to 30 minutes if not set.", + }, + }, + ), } } func resourceIPAddressCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { c := m.(providerContext).legacyClient a := address.NewAPI(c) - prefixID := d.Get("network_prefix_id").(string) + v := vlan.NewAPI(c) + // if `vlan_id` was provided, we will perform an ip reservation + if vlanID, ok := d.GetOk("vlan_id"); ok { + vlan, err := v.Get(ctx, vlanID.(string)) + if err != nil { + return diag.FromErr(fmt.Errorf("fetch vlan: %w", err)) + } else if len(vlan.Locations) < 1 { + return diag.Errorf("vlan has no locations specified") + } + + reserveOpts := address.ReserveRandom{ + VlanID: vlan.Identifier, + LocationID: vlan.Locations[0].Identifier, + Count: 1, + } + + if reservationPeriodSeconds, ok := d.GetOk("reservation_period_seconds"); ok { + reserveOpts.ReservationPeriod = uint(reservationPeriodSeconds.(int)) + } + + if prefixID, ok := d.GetOk("network_prefix_id"); ok { + reserveOpts.PrefixID = prefixID.(string) + } else if ipVersion, ok := d.GetOk("version"); ok { + reserveOpts.IPVersion = address.IPReserveVersionLimit(ipVersion.(int)) + } + + reserveSummary, err := a.ReserveRandom(ctx, reserveOpts) + if err != nil { + return diag.FromErr(fmt.Errorf("reserve address: %w", err)) + } else if len(reserveSummary.Data) < 1 { + return diag.Errorf("reserve endpoint didn't return any addresses") + } + + d.SetId(reserveSummary.Data[0].ID) + + if err := retry.RetryContext(ctx, d.Timeout(schema.TimeoutCreate), func() *retry.RetryError { + if addr, err := a.Get(ctx, reserveSummary.Data[0].ID); err != nil { + return retry.NonRetryableError(err) + } else if addr.VLANID == "" { + return retry.RetryableError(fmt.Errorf("VLAN id is not set")) + } + + return nil + }); err != nil { + return diag.FromErr(fmt.Errorf("wait for VLAN to be set on address resource: %w", err)) + } + + return resourceIPAddressRead(ctx, d, m) + } + + // create specific address def := address.Create{ - PrefixID: prefixID, Address: d.Get("address").(string), + PrefixID: d.Get("network_prefix_id").(string), DescriptionCustomer: d.Get("description_customer").(string), Role: d.Get("role").(string), Organization: d.Get("organization").(string), @@ -139,6 +267,24 @@ func resourceIPAddressDelete(ctx context.Context, d *schema.ResourceData, m inte c := m.(providerContext).legacyClient a := address.NewAPI(c) + if addr, err := a.Get(ctx, d.Id()); isLegacyNotFoundError(err) { + // handle not found error by just deleting the resource + d.SetId("") + return nil + } else if err != nil { + // return unhandled error + return diag.FromErr(err) + } else if addr.DescriptionInternal == "reserved" { + d.SetId("") + var diags diag.Diagnostics + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Could not delete reserved address", + Detail: "Reserved addresses cannot be deleted manually. They'll expire eventually.", + }) + return diags + } + err := a.Delete(ctx, d.Id()) if err != nil { if err := handleNotFoundError(err); err != nil { diff --git a/anxcloud/resource_ip_address_test.go b/anxcloud/resource_ip_address_test.go index 3419995e..a391e40c 100644 --- a/anxcloud/resource_ip_address_test.go +++ b/anxcloud/resource_ip_address_test.go @@ -3,7 +3,9 @@ package anxcloud import ( "context" "fmt" + "net/netip" "testing" + "time" "github.com/anexia-it/terraform-provider-anxcloud/anxcloud/testutils/environment" @@ -72,6 +74,138 @@ func TestAccAnxCloudIPAddressReserved(t *testing.T) { }) } +func TestAccAnxCloudIPAddressReserveAvailable(t *testing.T) { + environment.SkipIfNoEnvironment(t) + envInfo := environment.GetEnvInfo(t) + + var ( + v4TestPrefix netip.Prefix + v6TestPrefix netip.Prefix + ) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` + locals { + test_run_name = %q + } + + data "anxcloud_core_location" "anx04" { + code = "ANX04" + } + + resource "anxcloud_vlan" "foo" { + location_id = data.anxcloud_core_location.anx04.id + vm_provisioning = true + description_customer = "tf-acc-test ${local.test_run_name} anxcloud_ip_address reserve" + } + + resource "anxcloud_network_prefix" "v4" { + location_id = data.anxcloud_core_location.anx04.id + netmask = 28 + vlan_id = anxcloud_vlan.foo.id + ip_version = 4 + type = 1 + description_customer = "tf-acc-test ${local.test_run_name} anxcloud_ip_address reserve" + create_empty = true + } + + resource "anxcloud_network_prefix" "v6" { + location_id = data.anxcloud_core_location.anx04.id + netmask = 64 + vlan_id = anxcloud_vlan.foo.id + ip_version = 6 + type = 1 + description_customer = "tf-acc-test ${local.test_run_name} anxcloud_ip_address reserve" + create_empty = true + } + + resource "anxcloud_ip_address" "v4version" { + vlan_id = anxcloud_vlan.foo.id + version = 4 + reservation_period_seconds = 60 + + depends_on = [ + anxcloud_network_prefix.v4, + ] + } + + resource "anxcloud_ip_address" "v6version" { + vlan_id = anxcloud_vlan.foo.id + version = 6 + reservation_period_seconds = 60 + + depends_on = [ + anxcloud_network_prefix.v6, + ] + } + + resource "anxcloud_ip_address" "v4prefix" { + vlan_id = anxcloud_vlan.foo.id + network_prefix_id = anxcloud_network_prefix.v4.id + reservation_period_seconds = 60 + } + + resource "anxcloud_ip_address" "v6prefix" { + vlan_id = anxcloud_vlan.foo.id + network_prefix_id = anxcloud_network_prefix.v6.id + reservation_period_seconds = 60 + } + + resource "anxcloud_ip_address" "anyprefixorversion" { + vlan_id = anxcloud_vlan.foo.id + reservation_period_seconds = 60 + + depends_on = [ + anxcloud_network_prefix.v4, + anxcloud_network_prefix.v6, + ] + } + `, envInfo.TestRunName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrWith("anxcloud_network_prefix.v4", "cidr", func(value string) error { + v4TestPrefix = netip.MustParsePrefix(value) + return nil + }), + resource.TestCheckResourceAttrWith("anxcloud_network_prefix.v6", "cidr", func(value string) error { + v6TestPrefix = netip.MustParsePrefix(value) + return nil + }), + resource.TestCheckResourceAttrWith("anxcloud_ip_address.v4version", "address", func(value string) error { + if !netip.MustParseAddr(value).Is4() { + return fmt.Errorf("not a v4 address") + } + return nil + }), + resource.TestCheckResourceAttrWith("anxcloud_ip_address.v6version", "address", func(value string) error { + if !netip.MustParseAddr(value).Is6() { + return fmt.Errorf("not a v6 address") + } + return nil + }), + resource.TestCheckResourceAttrWith("anxcloud_ip_address.v4prefix", "address", func(value string) error { + if !v4TestPrefix.Contains(netip.MustParseAddr(value)) { + return fmt.Errorf("address not in v4 test prefix") + } + return nil + }), + resource.TestCheckResourceAttrWith("anxcloud_ip_address.v6prefix", "address", func(value string) error { + if !v6TestPrefix.Contains(netip.MustParseAddr(value)) { + return fmt.Errorf("address not in v6 test prefix") + } + return nil + }), + ), + }, + // wait for the reservations to expire before tearing down prefixes + {PreConfig: func() { time.Sleep(10 * time.Minute) }, Config: "# empty config"}, + }, + }) +} + func TestAccAnxCloudIPAddressTags(t *testing.T) { environment.SkipIfNoEnvironment(t) envInfo := environment.GetEnvInfo(t) diff --git a/anxcloud/schema_ip_address.go b/anxcloud/schema_ip_address.go index 3f432e49..2d87d019 100644 --- a/anxcloud/schema_ip_address.go +++ b/anxcloud/schema_ip_address.go @@ -42,61 +42,3 @@ func schemaIPAddresses() map[string]*schema.Schema { }, } } - -func schemaIPAddress() map[string]*schema.Schema { - return map[string]*schema.Schema{ - "id": { - Type: schema.TypeString, - Computed: true, - Description: identifierDescription, - }, - "network_prefix_id": { - Type: schema.TypeString, - Required: true, - ForceNew: true, - Description: "Identifier of the related network prefix.", - }, - "address": { - Type: schema.TypeString, - Required: true, - ForceNew: true, - Description: "IP address.", - }, - "description_customer": { - Type: schema.TypeString, - Optional: true, - Description: "Additional customer description.", - }, - "description_internal": { - Type: schema.TypeString, - Computed: true, - Description: "Internal description.", - }, - "role": { - Type: schema.TypeString, - Optional: true, - Default: "Default", - Description: "Role of the IP address", - }, - "organization": { - Type: schema.TypeString, - Optional: true, - Description: "Customer of yours. Reseller only.", - }, - "version": { - Type: schema.TypeInt, - Computed: true, - Description: "IP version.", - }, - "vlan_id": { - Type: schema.TypeString, - Computed: true, - Description: "The associated VLAN identifier.", - }, - "status": { - Type: schema.TypeString, - Computed: true, - Description: "Status of the IP address", - }, - } -} diff --git a/docs/resources/ip_address.md b/docs/resources/ip_address.md index 67e37b03..4464eb30 100644 --- a/docs/resources/ip_address.md +++ b/docs/resources/ip_address.md @@ -3,49 +3,67 @@ page_title: "anxcloud_ip_address Resource - terraform-provider-anxcloud" subcategory: "" description: |- - This resource allows you to create and configure IP addresses. + This resource allows you to create and configure IP addresses. Addresses created without the address attribute will expire if the reservation period exceeds before assigned to a VM. --- # anxcloud_ip_address (Resource) -This resource allows you to create and configure IP addresses. +This resource allows you to create and configure IP addresses. Addresses created without the `address` attribute will expire if the reservation period exceeds before assigned to a VM. ## Example Usage ```terraform -data "anxcloud_core_location" "anx04" { - code = "ANX04" +# create a specific address within a VLAN +resource "anxcloud_ip_address" "example_specific" { + network_prefix_id = anxcloud_network_prefix.example.id + address = "10.20.30.1" } -resource "anxcloud_ip_address" "example" { - network_prefix_id = var.network_prefix_id - address = "10.20.30.1" +# reserve an address in a specific prefix +resource "anxcloud_ip_address" "example_prefix_v4" { + vlan_id = anxcloud_vlan.example.id + network_prefix_id = anxcloud_network_prefix.example.id +} + +# reserve an address in any v4 prefix available in the specified VLAN +resource "anxcloud_ip_address" "example_version_v4" { + vlan_id = anxcloud_vlan.example.id + version = 4 +} + +# reserve an address in any v6 prefix available in the specified VLAN +resource "anxcloud_ip_address" "example_version_v6" { + vlan_id = anxcloud_vlan.example.id + version = 6 +} + +# reserve an address in any prefix available in the specified VLAN +resource "anxcloud_ip_address" "example_any_in_vlan" { + vlan_id = anxcloud_vlan.example.id } ``` ## Schema -### Required - -- `address` (String) IP address. -- `network_prefix_id` (String) Identifier of the related network prefix. - ### Optional +- `address` (String) IP address. - `description_customer` (String) Additional customer description. +- `network_prefix_id` (String) Identifier of the related network prefix. - `organization` (String) Customer of yours. Reseller only. +- `reservation_period_seconds` (Number) Period for the requested reservation in seconds. Defaults to 30 minutes if not set. - `role` (String) Role of the IP address - `tags` (Set of String) Set of tags attached to the resource. - `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) +- `version` (Number) IP version. +- `vlan_id` (String) The associated VLAN identifier. ### Read-Only - `description_internal` (String) Internal description. - `id` (String) Identifier of the API resource. - `status` (String) Status of the IP address -- `version` (Number) IP version. -- `vlan_id` (String) The associated VLAN identifier. ### Nested Schema for `timeouts` diff --git a/examples/resources/anxcloud_ip_address/resource.tf b/examples/resources/anxcloud_ip_address/resource.tf index db2a6a8d..27ce5ad5 100644 --- a/examples/resources/anxcloud_ip_address/resource.tf +++ b/examples/resources/anxcloud_ip_address/resource.tf @@ -1,8 +1,28 @@ -data "anxcloud_core_location" "anx04" { - code = "ANX04" +# create a specific address within a VLAN +resource "anxcloud_ip_address" "example_specific" { + network_prefix_id = anxcloud_network_prefix.example.id + address = "10.20.30.1" } -resource "anxcloud_ip_address" "example" { - network_prefix_id = var.network_prefix_id - address = "10.20.30.1" +# reserve an address in a specific prefix +resource "anxcloud_ip_address" "example_prefix_v4" { + vlan_id = anxcloud_vlan.example.id + network_prefix_id = anxcloud_network_prefix.example.id +} + +# reserve an address in any v4 prefix available in the specified VLAN +resource "anxcloud_ip_address" "example_version_v4" { + vlan_id = anxcloud_vlan.example.id + version = 4 +} + +# reserve an address in any v6 prefix available in the specified VLAN +resource "anxcloud_ip_address" "example_version_v6" { + vlan_id = anxcloud_vlan.example.id + version = 6 +} + +# reserve an address in any prefix available in the specified VLAN +resource "anxcloud_ip_address" "example_any_in_vlan" { + vlan_id = anxcloud_vlan.example.id } diff --git a/go.mod b/go.mod index 4671ebf7..e282a6b9 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/onsi/gomega v1.33.1 github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b github.com/stretchr/testify v1.8.4 - go.anx.io/go-anxcloud v0.7.0 + go.anx.io/go-anxcloud v0.7.1 k8s.io/client-go v0.29.1 ) diff --git a/go.sum b/go.sum index be6c358b..9973c409 100644 --- a/go.sum +++ b/go.sum @@ -212,8 +212,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.14.1 h1:t9fyA35fwjjUMcmL5hLER+e/rEPqrbCK1/OSE4SI9KA= github.com/zclconf/go-cty v1.14.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= -go.anx.io/go-anxcloud v0.7.0 h1:9qFz+1kxTo6HpB0+RTiDUjEq7T+q3luxZjrlwnQMrVI= -go.anx.io/go-anxcloud v0.7.0/go.mod h1:2RZ9hF/KTzGOr9MMa4rN+OJ9kgPT4PgGPbNa6qIeIn8= +go.anx.io/go-anxcloud v0.7.1 h1:6n0V+bI794j9vkG5k8w61hv1kOCQmcdMHorHtf+6Oag= +go.anx.io/go-anxcloud v0.7.1/go.mod h1:2RZ9hF/KTzGOr9MMa4rN+OJ9kgPT4PgGPbNa6qIeIn8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=