From b58bd0f640e6994fc0b41c50bade2a90738db7db Mon Sep 17 00:00:00 2001 From: GokceGK <161626272+GokceGK@users.noreply.github.com> Date: Fri, 9 Aug 2024 12:38:35 +0200 Subject: [PATCH] Onboard iaas network area (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Onboard network-area resource (#469) * onboard network-area resource * fix update network ranges * fix linter issues * add organization id to test util * add examples * change project count to computed and adapt unit tests * extend acceptance tests * add docs * fix linter issues * add datasource to provider * remove routes from the datasource schema * remove obsolete api cals * remove raw response from create network area * change network ranges to list of objects * update examples * fix linter issues * Update stackit/internal/services/iaas/networkarea/resource.go Co-authored-by: João Palet * add network range id to schema * map network_range_id * fix unit tests * adapt acceptance test * fix acceptance tests * Update stackit/internal/services/iaas/iaas_acc_test.go Co-authored-by: João Palet --------- Co-authored-by: João Palet * Add network area to beta resources list (#481) * add network area to beta resources list * add accidentally removed line * add accidentally removed line * Fix multi range creation issue (#483) * fix multi range creation issue * fix network range update issue * fix some unit tests * fix order issue * Update stackit/internal/services/iaas/networkarea/resource.go Co-authored-by: João Palet * add unit test to cover the reconciled list --------- Co-authored-by: João Palet * Onboard IaaS network area route (#491) * onboard network area route * generate docs * add route to beta resources * extend acceptance test * fix import id handling * Update next_hop description Co-authored-by: João Palet * Update prefix description Co-authored-by: João Palet * change descriptions in datasource * add IP and CIDR validators * use requiresReplace in resource * improve error logs * change the create response handling * update docs * change route and route id detection --------- Co-authored-by: João Palet --------- Co-authored-by: João Palet --- docs/data-sources/network_area.md | 51 + docs/data-sources/network_area_route.md | 39 + docs/guides/opting_into_beta_resources.md | 5 +- docs/resources/network_area.md | 62 ++ docs/resources/network_area_route.md | 39 + .../stackit_network_area/data-source.tf | 4 + .../stackit_network_area_route/data-source.tf | 5 + .../stackit_network_area/resource.tf | 10 + .../stackit_network_area_route/resource.tf | 6 + .../internal/services/iaas/iaas_acc_test.go | 197 +++- .../services/iaas/networkarea/datasource.go | 229 ++++ .../services/iaas/networkarea/resource.go | 746 +++++++++++++ .../iaas/networkarea/resource_test.go | 978 ++++++++++++++++++ .../iaas/networkarearoute/datasource.go | 171 +++ .../iaas/networkarearoute/resource.go | 383 +++++++ .../iaas/networkarearoute/resource_test.go | 147 +++ stackit/internal/testutil/testutil.go | 2 + stackit/provider.go | 6 + .../guides/opting_into_beta_resources.md.tmpl | 5 +- 19 files changed, 3077 insertions(+), 8 deletions(-) create mode 100644 docs/data-sources/network_area.md create mode 100644 docs/data-sources/network_area_route.md create mode 100644 docs/resources/network_area.md create mode 100644 docs/resources/network_area_route.md create mode 100644 examples/data-sources/stackit_network_area/data-source.tf create mode 100644 examples/data-sources/stackit_network_area_route/data-source.tf create mode 100644 examples/resources/stackit_network_area/resource.tf create mode 100644 examples/resources/stackit_network_area_route/resource.tf create mode 100644 stackit/internal/services/iaas/networkarea/datasource.go create mode 100644 stackit/internal/services/iaas/networkarea/resource.go create mode 100644 stackit/internal/services/iaas/networkarea/resource_test.go create mode 100644 stackit/internal/services/iaas/networkarearoute/datasource.go create mode 100644 stackit/internal/services/iaas/networkarearoute/resource.go create mode 100644 stackit/internal/services/iaas/networkarearoute/resource_test.go diff --git a/docs/data-sources/network_area.md b/docs/data-sources/network_area.md new file mode 100644 index 00000000..4b9d8eda --- /dev/null +++ b/docs/data-sources/network_area.md @@ -0,0 +1,51 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_network_area Data Source - stackit" +subcategory: "" +description: |- + Network area datasource schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_network_area (Data Source) + +Network area datasource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +data "stackit_network_area" "example" { + organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_area_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `network_area_id` (String) The network area ID. +- `organization_id` (String) STACKIT organization ID to which the network area is associated. + +### Read-Only + +- `default_nameservers` (List of String) List of DNS Servers/Nameservers. +- `default_prefix_length` (Number) The default prefix length for networks in the network area. +- `id` (String) Terraform's internal resource ID. It is structured as "`organization_id`,`network_area_id`". +- `max_prefix_length` (Number) The maximal prefix length for networks in the network area. +- `min_prefix_length` (Number) The minimal prefix length for networks in the network area. +- `name` (String) The name of the network area. +- `network_ranges` (Attributes List) List of Network ranges. (see [below for nested schema](#nestedatt--network_ranges)) +- `project_count` (Number) The amount of projects currently referencing this area. +- `transfer_network` (String) Classless Inter-Domain Routing (CIDR). + + +### Nested Schema for `network_ranges` + +Read-Only: + +- `network_range_id` (String) +- `prefix` (String) diff --git a/docs/data-sources/network_area_route.md b/docs/data-sources/network_area_route.md new file mode 100644 index 00000000..fc66c505 --- /dev/null +++ b/docs/data-sources/network_area_route.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_network_area_route Data Source - stackit" +subcategory: "" +description: |- + Network area route data source schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_network_area_route (Data Source) + +Network area route data source schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +data "stackit_network_area_route" "example" { + organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_area_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_area_route_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `network_area_id` (String) The network area ID to which the network area route is associated. +- `network_area_route_id` (String) The network area route ID. +- `organization_id` (String) STACKIT organization ID to which the network area is associated. + +### Read-Only + +- `id` (String) Terraform's internal data source ID. It is structured as "`organization_id`,`network_area_id`,`network_area_route_id`". +- `next_hop` (String) The IP address of the routing system, that will route the prefix configured. Should be a valid IPv4 address. +- `prefix` (String) The network, that is reachable though the Next Hop. Should use CIDR notation. diff --git a/docs/guides/opting_into_beta_resources.md b/docs/guides/opting_into_beta_resources.md index 6378e1c8..7512647e 100644 --- a/docs/guides/opting_into_beta_resources.md +++ b/docs/guides/opting_into_beta_resources.md @@ -37,8 +37,11 @@ export STACKIT_TF_ENABLE_BETA_RESOURCES=true ## Listing Beta Resources - [`stackit_server_backup_schedule`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/server_backup_schedule) +- [`stackit_network_area`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/network_area) +- [`stackit_network_area_route`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/network_area_route) ## Listing Beta Data Sources - [`stackit_server_backup_schedule`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/server_backup_schedule) -- [`stackit_server_backup_schedules`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/server_backup_schedules) \ No newline at end of file +- [`stackit_server_backup_schedules`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/server_backup_schedules) +- [`stackit_network_area_route`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/network_area_route) \ No newline at end of file diff --git a/docs/resources/network_area.md b/docs/resources/network_area.md new file mode 100644 index 00000000..768b9245 --- /dev/null +++ b/docs/resources/network_area.md @@ -0,0 +1,62 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_network_area Resource - stackit" +subcategory: "" +description: |- + Network area resource schema. Must have a region specified in the provider configuration. +--- + +# stackit_network_area (Resource) + +Network area resource schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +resource "stackit_network_area" "example" { + organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-network-area" + network_ranges = [ + { + prefix = "1.2.3.4" + } + ] + transfer_network = "1.2.3.4/5" +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the network area. +- `network_ranges` (Attributes List) List of Network ranges. (see [below for nested schema](#nestedatt--network_ranges)) +- `organization_id` (String) STACKIT organization ID to which the network area is associated. +- `transfer_network` (String) Classless Inter-Domain Routing (CIDR). + +### Optional + +- `default_nameservers` (List of String) List of DNS Servers/Nameservers. +- `default_prefix_length` (Number) The default prefix length for networks in the network area. +- `max_prefix_length` (Number) The maximal prefix length for networks in the network area. +- `min_prefix_length` (Number) The minimal prefix length for networks in the network area. + +### Read-Only + +- `id` (String) Network area resource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. +- `network_area_id` (String) The network area ID. +- `project_count` (Number) The amount of projects currently referencing this area. + + +### Nested Schema for `network_ranges` + +Required: + +- `prefix` (String) Classless Inter-Domain Routing (CIDR). + +Read-Only: + +- `network_range_id` (String) diff --git a/docs/resources/network_area_route.md b/docs/resources/network_area_route.md new file mode 100644 index 00000000..ba4bf998 --- /dev/null +++ b/docs/resources/network_area_route.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_network_area_route Resource - stackit" +subcategory: "" +description: |- + Network area route resource schema. Must have a region specified in the provider configuration. +--- + +# stackit_network_area_route (Resource) + +Network area route resource schema. Must have a `region` specified in the provider configuration. + +## Example Usage + +```terraform +resource "stackit_network_area" "example" { + organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_area_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + prefix = "1.2.3.4/5" + next_hop = "6.7.8.9" +} +``` + + +## Schema + +### Required + +- `network_area_id` (String) The network area ID to which the network area route is associated. +- `next_hop` (String) The IP address of the routing system, that will route the prefix configured. Should be a valid IPv4 address. +- `organization_id` (String) STACKIT organization ID to which the network area is associated. +- `prefix` (String) The network, that is reachable though the Next Hop. Should use CIDR notation. + +### Read-Only + +- `id` (String) Network area route resource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. +- `network_area_route_id` (String) The network area route ID. diff --git a/examples/data-sources/stackit_network_area/data-source.tf b/examples/data-sources/stackit_network_area/data-source.tf new file mode 100644 index 00000000..74872c56 --- /dev/null +++ b/examples/data-sources/stackit_network_area/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_network_area" "example" { + organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_area_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/data-sources/stackit_network_area_route/data-source.tf b/examples/data-sources/stackit_network_area_route/data-source.tf new file mode 100644 index 00000000..3f0db94d --- /dev/null +++ b/examples/data-sources/stackit_network_area_route/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_network_area_route" "example" { + organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_area_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_area_route_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_network_area/resource.tf b/examples/resources/stackit_network_area/resource.tf new file mode 100644 index 00000000..9d2267ad --- /dev/null +++ b/examples/resources/stackit_network_area/resource.tf @@ -0,0 +1,10 @@ +resource "stackit_network_area" "example" { + organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-network-area" + network_ranges = [ + { + prefix = "1.2.3.4" + } + ] + transfer_network = "1.2.3.4/5" +} diff --git a/examples/resources/stackit_network_area_route/resource.tf b/examples/resources/stackit_network_area_route/resource.tf new file mode 100644 index 00000000..48a608cc --- /dev/null +++ b/examples/resources/stackit_network_area_route/resource.tf @@ -0,0 +1,6 @@ +resource "stackit_network_area" "example" { + organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_area_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + prefix = "1.2.3.4/5" + next_hop = "6.7.8.9" +} diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index 25149aac..407df54f 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -25,10 +25,22 @@ var networkResource = map[string]string{ "nameserver1": "5.6.7.8", } -func resourceConfig(name, nameservers string) string { - return fmt.Sprintf(` - %s +var networkAreaResource = map[string]string{ + "organization_id": testutil.OrganizationId, + "name": fmt.Sprintf("acc-test-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)), + "networkrange0": "10.0.0.0/16", + "transfer_network": "10.1.2.0/24", +} +var networkAreaRouteResource = map[string]string{ + "organization_id": networkAreaResource["organization_id"], + "network_area_id": networkAreaResource["network_area_id"], + "prefix": "1.1.1.0/24", + "next_hop": "1.1.1.1", +} + +func networkResourceConfig(name, nameservers string) string { + return fmt.Sprintf(` resource "stackit_network" "network" { project_id = "%s" name = "%s" @@ -36,7 +48,6 @@ func resourceConfig(name, nameservers string) string { nameservers = %s } `, - testutil.IaaSProviderConfig(), networkResource["project_id"], name, networkResource["ipv4_prefix_length"], @@ -44,6 +55,47 @@ func resourceConfig(name, nameservers string) string { ) } +func networkAreaResourceConfig(areaname, networkranges string) string { + return fmt.Sprintf(` + resource "stackit_network_area" "network_area" { + organization_id = "%s" + name = "%s" + network_ranges = [{ + prefix = "%s" + }] + transfer_network = "%s" + } + `, + networkAreaResource["organization_id"], + areaname, + networkranges, + networkAreaResource["transfer_network"], + ) +} + +func networkAreaRouteResourceConfig() string { + return fmt.Sprintf(` + resource "stackit_network_area_route" "network_area_route" { + organization_id = stackit_network_area.network_area.organization_id + network_area_id = stackit_network_area.network_area.network_area_id + prefix = "%s" + next_hop = "%s" + } + `, + networkAreaRouteResource["prefix"], + networkAreaRouteResource["next_hop"], + ) +} + +func resourceConfig(name, nameservers, areaname, networkranges string) string { + return fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s", + testutil.IaaSProviderConfig(), + networkResourceConfig(name, nameservers), + networkAreaResourceConfig(areaname, networkranges), + networkAreaRouteResourceConfig(), + ) +} + func TestAccIaaS(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -58,6 +110,8 @@ func TestAccIaaS(t *testing.T) { "[%q]", networkResource["nameserver0"], ), + networkAreaResource["name"], + networkAreaResource["networkrange0"], ), Check: resource.ComposeAggregateTestCheckFunc( // Instance @@ -66,23 +120,58 @@ func TestAccIaaS(t *testing.T) { resource.TestCheckResourceAttr("stackit_network.network", "name", networkResource["name"]), resource.TestCheckResourceAttr("stackit_network.network", "nameservers.#", "1"), resource.TestCheckResourceAttr("stackit_network.network", "nameservers.0", networkResource["nameserver0"]), + + // Network Area + resource.TestCheckResourceAttr("stackit_network_area.network_area", "organization_id", networkAreaResource["organization_id"]), + resource.TestCheckResourceAttrSet("stackit_network_area.network_area", "network_area_id"), + resource.TestCheckResourceAttr("stackit_network_area.network_area", "name", networkAreaResource["name"]), + resource.TestCheckResourceAttr("stackit_network_area.network_area", "network_ranges.#", "1"), + resource.TestCheckResourceAttr("stackit_network_area.network_area", "network_ranges.0.prefix", networkAreaResource["networkrange0"]), + resource.TestCheckResourceAttrSet("stackit_network_area.network_area", "network_ranges.0.network_range_id"), + + // Network Area Route + resource.TestCheckResourceAttrPair( + "stackit_network_area_route.network_area_route", "organization_id", + "stackit_network_area.network_area", "organization_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_network_area_route.network_area_route", "network_area_id", + "stackit_network_area.network_area", "network_area_id", + ), + resource.TestCheckResourceAttrSet("stackit_network_area_route.network_area_route", "network_area_route_id"), + resource.TestCheckResourceAttr("stackit_network_area_route.network_area_route", "prefix", networkAreaRouteResource["prefix"]), + resource.TestCheckResourceAttr("stackit_network_area_route.network_area_route", "next_hop", networkAreaRouteResource["next_hop"]), ), }, // Data source { Config: fmt.Sprintf(` %s - + data "stackit_network" "network" { project_id = stackit_network.network.project_id network_id = stackit_network.network.network_id - }`, + } + + data "stackit_network_area" "network_area" { + organization_id = stackit_network_area.network_area.organization_id + network_area_id = stackit_network_area.network_area.network_area_id + } + + data "stackit_network_area_route" "network_area_route" { + organization_id = stackit_network_area.network_area.organization_id + network_area_id = stackit_network_area.network_area.network_area_id + network_area_route_id = stackit_network_area_route.network_area_route.network_area_route_id + } + `, resourceConfig( networkResource["name"], fmt.Sprintf( "[%q]", networkResource["nameserver0"], ), + networkAreaResource["name"], + networkAreaResource["networkrange0"], ), ), Check: resource.ComposeAggregateTestCheckFunc( @@ -94,6 +183,29 @@ func TestAccIaaS(t *testing.T) { ), resource.TestCheckResourceAttr("data.stackit_network.network", "name", networkResource["name"]), resource.TestCheckResourceAttr("data.stackit_network.network", "nameservers.0", networkResource["nameserver0"]), + + // Network area + resource.TestCheckResourceAttr("data.stackit_network_area.network_area", "organization_id", networkAreaResource["organization_id"]), + resource.TestCheckResourceAttrPair( + "stackit_network_area.network_area", "network_area_id", + "data.stackit_network_area.network_area", "network_area_id", + ), + resource.TestCheckResourceAttr("data.stackit_network_area.network_area", "name", networkAreaResource["name"]), + resource.TestCheckResourceAttr("data.stackit_network_area.network_area", "network_ranges.#", "1"), + resource.TestCheckResourceAttr("data.stackit_network_area.network_area", "network_ranges.0.prefix", networkAreaResource["networkrange0"]), + + // Network area route + resource.TestCheckResourceAttrPair( + "stackit_network_area_route.network_area_route", "organization_id", + "stackit_network_area.network_area", "organization_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_network_area_route.network_area_route", "network_area_id", + "stackit_network_area.network_area", "network_area_id", + ), + resource.TestCheckResourceAttrSet("stackit_network_area_route.network_area_route", "network_area_route_id"), + resource.TestCheckResourceAttr("stackit_network_area_route.network_area_route", "prefix", networkAreaRouteResource["prefix"]), + resource.TestCheckResourceAttr("stackit_network_area_route.network_area_route", "next_hop", networkAreaRouteResource["next_hop"]), ), }, // Import @@ -114,6 +226,42 @@ func TestAccIaaS(t *testing.T) { ImportStateVerify: true, ImportStateVerifyIgnore: []string{"ipv4_prefix_length"}, // Field is not returned by the API }, + { + ResourceName: "stackit_network_area.network_area", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_network_area.network_area"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_network_area.network_area") + } + networkAreaId, ok := r.Primary.Attributes["network_area_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_area_id") + } + return fmt.Sprintf("%s,%s", testutil.OrganizationId, networkAreaId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "stackit_network_area_route.network_area_route", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_network_area_route.network_area_route"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_network_area_route.network_area_route") + } + networkAreaId, ok := r.Primary.Attributes["network_area_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_area_id") + } + networkAreaRouteId, ok := r.Primary.Attributes["network_area_route_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_area_route_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.OrganizationId, networkAreaId, networkAreaRouteId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, // Update { Config: resourceConfig( @@ -123,6 +271,8 @@ func TestAccIaaS(t *testing.T) { networkResource["nameserver0"], networkResource["nameserver1"], ), + fmt.Sprintf("%s-updated", networkAreaResource["name"]), + networkAreaResource["networkrange0"], ), Check: resource.ComposeAggregateTestCheckFunc( // Instance @@ -132,6 +282,13 @@ func TestAccIaaS(t *testing.T) { resource.TestCheckResourceAttr("stackit_network.network", "nameservers.#", "2"), resource.TestCheckResourceAttr("stackit_network.network", "nameservers.0", networkResource["nameserver0"]), resource.TestCheckResourceAttr("stackit_network.network", "nameservers.1", networkResource["nameserver1"]), + + // Network area + resource.TestCheckResourceAttr("stackit_network_area.network_area", "organization_id", networkAreaResource["organization_id"]), + resource.TestCheckResourceAttrSet("stackit_network_area.network_area", "network_area_id"), + resource.TestCheckResourceAttr("stackit_network_area.network_area", "name", fmt.Sprintf("%s-updated", networkAreaResource["name"])), + resource.TestCheckResourceAttr("stackit_network_area.network_area", "network_ranges.#", "1"), + resource.TestCheckResourceAttr("stackit_network_area.network_area", "network_ranges.0.prefix", networkAreaResource["networkrange0"]), ), }, // Deletion is done by the framework implicitly @@ -183,5 +340,33 @@ func testAccCheckIaaSDestroy(s *terraform.State) error { } } } + + // network areas + networkAreasToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_network_area" { + continue + } + networkAreaId := strings.Split(rs.Primary.ID, core.Separator)[1] + networkAreasToDestroy = append(networkAreasToDestroy, networkAreaId) + } + + networkAreasResp, err := client.ListNetworkAreasExecute(ctx, testutil.OrganizationId) + if err != nil { + return fmt.Errorf("getting networkAreasResp: %w", err) + } + + networkAreas := *networkAreasResp.Items + for i := range networkAreas { + if networkAreas[i].AreaId == nil { + continue + } + if utils.Contains(networkAreasToDestroy, *networkAreas[i].AreaId) { + err := client.DeleteNetworkAreaExecute(ctx, testutil.OrganizationId, *networkAreas[i].AreaId) + if err != nil { + return fmt.Errorf("destroying network area %s during CheckDestroy: %w", *networkAreas[i].AreaId, err) + } + } + } return nil } diff --git a/stackit/internal/services/iaas/networkarea/datasource.go b/stackit/internal/services/iaas/networkarea/datasource.go new file mode 100644 index 00000000..5af89f50 --- /dev/null +++ b/stackit/internal/services/iaas/networkarea/datasource.go @@ -0,0 +1,229 @@ +package networkarea + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// scheduleDataSourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var networkAreaDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &networkAreaDataSource{} +) + +// NewNetworkDataSource is a helper function to simplify the provider implementation. +func NewNetworkAreaDataSource() datasource.DataSource { + return &networkAreaDataSource{} +} + +// networkDataSource is the data source implementation. +type networkAreaDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *networkAreaDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_network_area" +} + +func (d *networkAreaDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + var apiClient *iaas.APIClient + var err error + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !networkAreaDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_network_area", "data source") + if resp.Diagnostics.HasError() { + return + } + networkAreaDataSourceBetaCheckDone = true + } + + if providerData.IaaSCustomEndpoint != "" { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err)) + return + } + + d.client = apiClient + tflog.Info(ctx, "IaaS client configured") +} + +// Schema defines the schema for the data source. +func (d *networkAreaDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Network area resource schema. Must have a `region` specified in the provider configuration.", + MarkdownDescription: features.AddBetaDescription("Network area datasource schema. Must have a `region` specified in the provider configuration."), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`\".", + Computed: true, + }, + "organization_id": schema.StringAttribute{ + Description: "STACKIT organization ID to which the network area is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_area_id": schema.StringAttribute{ + Description: "The network area ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the network area.", + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + }, + }, + "project_count": schema.Int64Attribute{ + Description: "The amount of projects currently referencing this area.", + Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + "default_nameservers": schema.ListAttribute{ + Description: "List of DNS Servers/Nameservers.", + Computed: true, + ElementType: types.StringType, + }, + "network_ranges": schema.ListNestedAttribute{ + Description: "List of Network ranges.", + Computed: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.SizeAtMost(64), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "network_range_id": schema.StringAttribute{ + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "prefix": schema.StringAttribute{ + Computed: true, + }, + }, + }, + }, + "transfer_network": schema.StringAttribute{ + Description: "Classless Inter-Domain Routing (CIDR).", + Computed: true, + }, + "default_prefix_length": schema.Int64Attribute{ + Description: "The default prefix length for networks in the network area.", + Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(24), + int64validator.AtMost(29), + }, + }, + "max_prefix_length": schema.Int64Attribute{ + Description: "The maximal prefix length for networks in the network area.", + Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(24), + int64validator.AtMost(29), + }, + }, + "min_prefix_length": schema.Int64Attribute{ + Description: "The minimal prefix length for networks in the network area.", + Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(22), + int64validator.AtMost(29), + }, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *networkAreaDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + organizationId := model.OrganizationId.ValueString() + networkAreaId := model.NetworkAreaId.ValueString() + ctx = tflog.SetField(ctx, "organization_id", organizationId) + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + + networkAreaResp, err := d.client.GetNetworkArea(ctx, organizationId, networkAreaId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area", fmt.Sprintf("Calling API: %v", err)) + return + } + + networkAreaRanges := networkAreaResp.Ipv4.NetworkRanges + + err = mapFields(ctx, networkAreaResp, networkAreaRanges, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network area read") +} diff --git a/stackit/internal/services/iaas/networkarea/resource.go b/stackit/internal/services/iaas/networkarea/resource.go new file mode 100644 index 00000000..ed396626 --- /dev/null +++ b/stackit/internal/services/iaas/networkarea/resource.go @@ -0,0 +1,746 @@ +package networkarea + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + internalUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &networkAreaResource{} + _ resource.ResourceWithConfigure = &networkAreaResource{} + _ resource.ResourceWithImportState = &networkAreaResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + OrganizationId types.String `tfsdk:"organization_id"` + NetworkAreaId types.String `tfsdk:"network_area_id"` + Name types.String `tfsdk:"name"` + ProjectCount types.Int64 `tfsdk:"project_count"` + DefaultNameservers types.List `tfsdk:"default_nameservers"` + NetworkRanges types.List `tfsdk:"network_ranges"` + TransferNetwork types.String `tfsdk:"transfer_network"` + DefaultPrefixLength types.Int64 `tfsdk:"default_prefix_length"` + MaxPrefixLength types.Int64 `tfsdk:"max_prefix_length"` + MinPrefixLength types.Int64 `tfsdk:"min_prefix_length"` +} + +// Struct corresponding to Model.NetworkRanges[i] +type networkRange struct { + Prefix types.String `tfsdk:"prefix"` + NetworkRangeId types.String `tfsdk:"network_range_id"` +} + +// Types corresponding to networkRanges +var networkRangeTypes = map[string]attr.Type{ + "prefix": types.StringType, + "network_range_id": types.StringType, +} + +// NewNetworkAreaResource is a helper function to simplify the provider implementation. +func NewNetworkAreaResource() resource.Resource { + return &networkAreaResource{} +} + +// networkResource is the resource implementation. +type networkAreaResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *networkAreaResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_network_area" +} + +// Configure adds the provider configured client to the resource. +func (r *networkAreaResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_network_area", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaas.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "IaaS client configured") +} + +// Schema defines the schema for the resource. +func (r *networkAreaResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Network area resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`\".", + MarkdownDescription: features.AddBetaDescription("Network area resource schema. Must have a `region` specified in the provider configuration."), + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "organization_id": schema.StringAttribute{ + Description: "STACKIT organization ID to which the network area is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_area_id": schema.StringAttribute{ + Description: "The network area ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the network area.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + }, + }, + "project_count": schema.Int64Attribute{ + Description: "The amount of projects currently referencing this area.", + Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + }, + }, + "default_nameservers": schema.ListAttribute{ + Description: "List of DNS Servers/Nameservers.", + Optional: true, + ElementType: types.StringType, + }, + "network_ranges": schema.ListNestedAttribute{ + Description: "List of Network ranges.", + Required: true, + Validators: []validator.List{ + listvalidator.SizeAtLeast(1), + listvalidator.SizeAtMost(64), + }, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "network_range_id": schema.StringAttribute{ + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "prefix": schema.StringAttribute{ + Description: "Classless Inter-Domain Routing (CIDR).", + Required: true, + }, + }, + }, + }, + "transfer_network": schema.StringAttribute{ + Description: "Classless Inter-Domain Routing (CIDR).", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "default_prefix_length": schema.Int64Attribute{ + Description: "The default prefix length for networks in the network area.", + Optional: true, + Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(24), + int64validator.AtMost(29), + }, + Default: int64default.StaticInt64(25), + }, + "max_prefix_length": schema.Int64Attribute{ + Description: "The maximal prefix length for networks in the network area.", + Optional: true, + Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(24), + int64validator.AtMost(29), + }, + Default: int64default.StaticInt64(29), + }, + "min_prefix_length": schema.Int64Attribute{ + Description: "The minimal prefix length for networks in the network area.", + Optional: true, + Computed: true, + Validators: []validator.Int64{ + int64validator.AtLeast(22), + int64validator.AtMost(29), + }, + Default: int64default.StaticInt64(24), + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *networkAreaResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + organizationId := model.OrganizationId.ValueString() + ctx = tflog.SetField(ctx, "organization_id", organizationId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new network area + area, err := r.client.CreateNetworkArea(ctx, organizationId).CreateNetworkAreaPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area", fmt.Sprintf("Calling API: %v", err)) + return + } + + networkArea, err := wait.CreateNetworkAreaWaitHandler(ctx, r.client, organizationId, *area.AreaId).WaitWithContext(context.Background()) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area", fmt.Sprintf("Network area creation waiting: %v", err)) + return + } + networkAreaId := *networkArea.AreaId + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + + networkAreaRanges := networkArea.Ipv4.NetworkRanges + + // Map response body to schema + err = mapFields(ctx, networkArea, networkAreaRanges, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network area created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *networkAreaResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + organizationId := model.OrganizationId.ValueString() + networkAreaId := model.NetworkAreaId.ValueString() + ctx = tflog.SetField(ctx, "organization_id", organizationId) + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + + networkAreaResp, err := r.client.GetNetworkArea(ctx, organizationId, networkAreaId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area", fmt.Sprintf("Calling API: %v", err)) + return + } + + networkAreaRanges := networkAreaResp.Ipv4.NetworkRanges + + // Map response body to schema + err = mapFields(ctx, networkAreaResp, networkAreaRanges, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network area read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *networkAreaResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + organizationId := model.OrganizationId.ValueString() + networkAreaId := model.NetworkAreaId.ValueString() + ctx = tflog.SetField(ctx, "organization_id", organizationId) + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + + ranges := []networkRange{} + if !(model.NetworkRanges.IsNull() || model.NetworkRanges.IsUnknown()) { + diags = model.NetworkRanges.ElementsAs(ctx, &ranges, false) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + // Generate API request body from model + payload, err := toUpdatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing network + _, err = r.client.PartialUpdateNetworkArea(ctx, organizationId, networkAreaId).PartialUpdateNetworkAreaPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area", fmt.Sprintf("Calling API: %v", err)) + return + } + waitResp, err := wait.UpdateNetworkAreaWaitHandler(ctx, r.client, organizationId, networkAreaId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area", fmt.Sprintf("Network area update waiting: %v", err)) + return + } + + // Update network ranges + err = updateNetworkRanges(ctx, organizationId, networkAreaId, ranges, r.client) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area", fmt.Sprintf("Updating Network ranges: %v", err)) + return + } + + networkAreaResp, err := r.client.GetNetworkArea(ctx, organizationId, networkAreaId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area", fmt.Sprintf("Calling API: %v", err)) + return + } + + networkAreaRanges := networkAreaResp.Ipv4.NetworkRanges + + err = mapFields(ctx, waitResp, networkAreaRanges, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network area updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *networkAreaResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + organizationId := model.OrganizationId.ValueString() + networkAreaId := model.NetworkAreaId.ValueString() + ctx = tflog.SetField(ctx, "organization_id", organizationId) + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + + // Delete existing network + err := r.client.DeleteNetworkArea(ctx, organizationId, networkAreaId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area", fmt.Sprintf("Calling API: %v", err)) + return + } + _, err = wait.DeleteNetworkAreaWaitHandler(ctx, r.client, organizationId, networkAreaId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area", fmt.Sprintf("Network area deletion waiting: %v", err)) + return + } + + tflog.Info(ctx, "Network area deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,network_id +func (r *networkAreaResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing network area", + fmt.Sprintf("Expected import identifier with format: [organization_id],[network_area_id] Got: %q", req.ID), + ) + return + } + + organizationId := idParts[0] + networkAreaId := idParts[1] + ctx = tflog.SetField(ctx, "organization_id", organizationId) + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), organizationId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_area_id"), networkAreaId)...) + tflog.Info(ctx, "Network state imported") +} + +func mapFields(ctx context.Context, networkAreaResp *iaas.NetworkArea, networkAreaRangesResp *[]iaas.NetworkRange, model *Model) error { + if networkAreaResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var networkAreaId string + if model.NetworkAreaId.ValueString() != "" { + networkAreaId = model.NetworkAreaId.ValueString() + } else if networkAreaResp.AreaId != nil { + networkAreaId = *networkAreaResp.AreaId + } else { + return fmt.Errorf("network area id not present") + } + + idParts := []string{ + model.OrganizationId.ValueString(), + networkAreaId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + if networkAreaResp.Ipv4 == nil || networkAreaResp.Ipv4.DefaultNameservers == nil { + model.DefaultNameservers = types.ListNull(types.StringType) + } else { + respDefaultNameservers := *networkAreaResp.Ipv4.DefaultNameservers + modelDefaultNameservers, err := internalUtils.ListValuetoStringSlice(model.DefaultNameservers) + if err != nil { + return fmt.Errorf("get current network area default nameservers from model: %w", err) + } + + reconciledDefaultNameservers := internalUtils.ReconcileStringSlices(modelDefaultNameservers, respDefaultNameservers) + + defaultNameserversTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledDefaultNameservers) + if diags.HasError() { + return fmt.Errorf("map network area default nameservers: %w", core.DiagsToError(diags)) + } + + model.DefaultNameservers = defaultNameserversTF + } + + err := mapNetworkRanges(ctx, networkAreaRangesResp, model) + if err != nil { + return fmt.Errorf("mapping network ranges: %w", err) + } + + model.NetworkAreaId = types.StringValue(networkAreaId) + model.Name = types.StringPointerValue(networkAreaResp.Name) + model.ProjectCount = types.Int64PointerValue(networkAreaResp.ProjectCount) + + if networkAreaResp.Ipv4 != nil { + model.TransferNetwork = types.StringPointerValue(networkAreaResp.Ipv4.TransferNetwork) + model.DefaultPrefixLength = types.Int64PointerValue(networkAreaResp.Ipv4.DefaultPrefixLen) + model.MaxPrefixLength = types.Int64PointerValue(networkAreaResp.Ipv4.MaxPrefixLen) + model.MinPrefixLength = types.Int64PointerValue(networkAreaResp.Ipv4.MinPrefixLen) + } + + return nil +} + +func mapNetworkRanges(ctx context.Context, networkAreaRangesList *[]iaas.NetworkRange, model *Model) error { + var diags diag.Diagnostics + + if networkAreaRangesList == nil { + return fmt.Errorf("nil network area ranges list") + } + if len(*networkAreaRangesList) == 0 { + model.NetworkRanges = types.ListNull(types.ObjectType{AttrTypes: networkRangeTypes}) + return nil + } + + ranges := []networkRange{} + if !(model.NetworkRanges.IsNull() || model.NetworkRanges.IsUnknown()) { + diags = model.NetworkRanges.ElementsAs(ctx, &ranges, false) + if diags.HasError() { + return fmt.Errorf("map network ranges: %w", core.DiagsToError(diags)) + } + } + + modelNetworkRangePrefixes := []string{} + for _, m := range ranges { + modelNetworkRangePrefixes = append(modelNetworkRangePrefixes, m.Prefix.ValueString()) + } + + apiNetworkRangePrefixes := []string{} + for _, n := range *networkAreaRangesList { + apiNetworkRangePrefixes = append(apiNetworkRangePrefixes, *n.Prefix) + } + + reconciledRangePrefixes := internalUtils.ReconcileStringSlices(modelNetworkRangePrefixes, apiNetworkRangePrefixes) + + networkRangesList := []attr.Value{} + for i, prefix := range reconciledRangePrefixes { + var networkRangeId string + for _, networkRangeElement := range *networkAreaRangesList { + if *networkRangeElement.Prefix == prefix { + networkRangeId = *networkRangeElement.NetworkRangeId + break + } + } + networkRangeMap := map[string]attr.Value{ + "prefix": types.StringValue(prefix), + "network_range_id": types.StringValue(networkRangeId), + } + + networkRangeTF, diags := types.ObjectValue(networkRangeTypes, networkRangeMap) + if diags.HasError() { + return fmt.Errorf("mapping index %d: %w", i, core.DiagsToError(diags)) + } + + networkRangesList = append(networkRangesList, networkRangeTF) + } + + networkRangesTF, diags := types.ListValue( + types.ObjectType{AttrTypes: networkRangeTypes}, + networkRangesList, + ) + if diags.HasError() { + return core.DiagsToError(diags) + } + + model.NetworkRanges = networkRangesTF + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkAreaPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + modelDefaultNameservers := []string{} + for _, ns := range model.DefaultNameservers.Elements() { + nameserverString, ok := ns.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + modelDefaultNameservers = append(modelDefaultNameservers, nameserverString.ValueString()) + } + + networkRangesPayload, err := toNetworkRangesPayload(ctx, model) + if err != nil { + return nil, fmt.Errorf("converting network ranges: %w", err) + } + + return &iaas.CreateNetworkAreaPayload{ + Name: conversion.StringValueToPointer(model.Name), + AddressFamily: &iaas.CreateAreaAddressFamily{ + Ipv4: &iaas.CreateAreaIPv4{ + DefaultNameservers: &modelDefaultNameservers, + NetworkRanges: networkRangesPayload, + TransferNetwork: conversion.StringValueToPointer(model.TransferNetwork), + DefaultPrefixLen: conversion.Int64ValueToPointer(model.DefaultPrefixLength), + MaxPrefixLen: conversion.Int64ValueToPointer(model.MaxPrefixLength), + MinPrefixLen: conversion.Int64ValueToPointer(model.MinPrefixLength), + }, + }, + }, nil +} + +func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkAreaPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + modelDefaultNameservers := []string{} + for _, ns := range model.DefaultNameservers.Elements() { + nameserverString, ok := ns.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + modelDefaultNameservers = append(modelDefaultNameservers, nameserverString.ValueString()) + } + + return &iaas.PartialUpdateNetworkAreaPayload{ + Name: conversion.StringValueToPointer(model.Name), + AddressFamily: &iaas.UpdateAreaAddressFamily{ + Ipv4: &iaas.UpdateAreaIPv4{ + DefaultNameservers: &modelDefaultNameservers, + DefaultPrefixLen: conversion.Int64ValueToPointer(model.DefaultPrefixLength), + MaxPrefixLen: conversion.Int64ValueToPointer(model.MaxPrefixLength), + MinPrefixLen: conversion.Int64ValueToPointer(model.MinPrefixLength), + }, + }, + }, nil +} + +func toNetworkRangesPayload(ctx context.Context, model *Model) (*[]iaas.NetworkRange, error) { + if model.NetworkRanges.IsNull() || model.NetworkRanges.IsUnknown() { + return nil, nil + } + + networkRangesModel := []networkRange{} + diags := model.NetworkRanges.ElementsAs(ctx, &networkRangesModel, false) + if diags.HasError() { + return nil, core.DiagsToError(diags) + } + + if len(networkRangesModel) == 0 { + return nil, nil + } + + payload := []iaas.NetworkRange{} + for i := range networkRangesModel { + networkRangeModel := networkRangesModel[i] + payload = append(payload, iaas.NetworkRange{ + Prefix: conversion.StringValueToPointer(networkRangeModel.Prefix), + }) + } + + return &payload, nil +} + +// updateNetworkRanges creates and deletes network ranges so that network area ranges are the ones in the model +func updateNetworkRanges(ctx context.Context, organizationId, networkAreaId string, ranges []networkRange, client *iaas.APIClient) error { + // Get network ranges current state + currentNetworkRangesResp, err := client.ListNetworkAreaRanges(ctx, organizationId, networkAreaId).Execute() + if err != nil { + return fmt.Errorf("error reading network area ranges: %w", err) + } + + type networkRangeState struct { + isInModel bool + isCreated bool + id string + } + + networkRangesState := make(map[string]*networkRangeState) + for _, nwRange := range ranges { + networkRangesState[nwRange.Prefix.ValueString()] = &networkRangeState{ + isInModel: true, + } + } + + for _, networkRange := range *currentNetworkRangesResp.Items { + prefix := *networkRange.Prefix + if _, ok := networkRangesState[prefix]; !ok { + networkRangesState[prefix] = &networkRangeState{} + } + networkRangesState[prefix].isCreated = true + networkRangesState[prefix].id = *networkRange.NetworkRangeId + } + + // Delete network ranges + for prefix, state := range networkRangesState { + if !state.isInModel && state.isCreated { + err := client.DeleteNetworkAreaRange(ctx, organizationId, networkAreaId, state.id).Execute() + if err != nil { + return fmt.Errorf("deleting network area range '%v': %w", prefix, err) + } + } + } + + // Create network ranges + for prefix, state := range networkRangesState { + if state.isInModel && !state.isCreated { + payload := iaas.CreateNetworkAreaRangePayload{ + Ipv4: &[]iaas.NetworkRange{ + { + Prefix: utils.Ptr(prefix), + }, + }, + } + + _, err := client.CreateNetworkAreaRange(ctx, organizationId, networkAreaId).CreateNetworkAreaRangePayload(payload).Execute() + if err != nil { + return fmt.Errorf("creating network range '%v': %w", prefix, err) + } + } + } + + return nil +} diff --git a/stackit/internal/services/iaas/networkarea/resource_test.go b/stackit/internal/services/iaas/networkarea/resource_test.go new file mode 100644 index 00000000..b6427e2f --- /dev/null +++ b/stackit/internal/services/iaas/networkarea/resource_test.go @@ -0,0 +1,978 @@ +package networkarea + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/gorilla/mux" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var testOrganizationId = uuid.NewString() +var testAreaId = uuid.NewString() +var testRangeId1 = uuid.NewString() +var testRangeId2 = uuid.NewString() +var testRangeId3 = uuid.NewString() +var testRangeId4 = uuid.NewString() +var testRangeId5 = uuid.NewString() +var testRangeId2Repeated = uuid.NewString() + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaas.NetworkArea + ListNetworkRanges *[]iaas.NetworkRange + expected Model + isValid bool + }{ + { + "id_ok", + Model{ + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId1), + "prefix": types.StringValue("prefix-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + }), + }, + &iaas.NetworkArea{ + AreaId: utils.Ptr("naid"), + Ipv4: &iaas.NetworkAreaIPv4{}, + }, + &[]iaas.NetworkRange{ + { + NetworkRangeId: utils.Ptr(testRangeId1), + Prefix: utils.Ptr("prefix-1"), + }, + { + NetworkRangeId: utils.Ptr(testRangeId2), + Prefix: utils.Ptr("prefix-2"), + }, + }, + + Model{ + Id: types.StringValue("oid,naid"), + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + Name: types.StringNull(), + DefaultNameservers: types.ListNull(types.StringType), + TransferNetwork: types.StringNull(), + DefaultPrefixLength: types.Int64Null(), + MaxPrefixLength: types.Int64Null(), + MinPrefixLength: types.Int64Null(), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId1), + "prefix": types.StringValue("prefix-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + }), + }, + true, + }, + { + "values_ok", + Model{ + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId1), + "prefix": types.StringValue("prefix-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + }), + }, + &iaas.NetworkArea{ + AreaId: utils.Ptr("naid"), + Ipv4: &iaas.NetworkAreaIPv4{ + DefaultNameservers: &[]string{ + "nameserver1", + "nameserver2", + }, + TransferNetwork: utils.Ptr("network"), + DefaultPrefixLen: utils.Ptr(int64(20)), + MaxPrefixLen: utils.Ptr(int64(22)), + MinPrefixLen: utils.Ptr(int64(18)), + }, + Name: utils.Ptr("name"), + }, + &[]iaas.NetworkRange{ + { + NetworkRangeId: utils.Ptr(testRangeId1), + Prefix: utils.Ptr("prefix-1"), + }, + { + NetworkRangeId: utils.Ptr(testRangeId2), + Prefix: utils.Ptr("prefix-2"), + }, + }, + Model{ + Id: types.StringValue("oid,naid"), + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + Name: types.StringValue("name"), + DefaultNameservers: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("nameserver1"), + types.StringValue("nameserver2"), + }), + TransferNetwork: types.StringValue("network"), + DefaultPrefixLength: types.Int64Value(20), + MaxPrefixLength: types.Int64Value(22), + MinPrefixLength: types.Int64Value(18), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId1), + "prefix": types.StringValue("prefix-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + }), + }, + true, + }, + { + "model and response have ranges in different order", + Model{ + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId1), + "prefix": types.StringValue("prefix-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + }), + }, + &iaas.NetworkArea{ + AreaId: utils.Ptr("naid"), + Ipv4: &iaas.NetworkAreaIPv4{ + DefaultNameservers: &[]string{ + "nameserver1", + "nameserver2", + }, + TransferNetwork: utils.Ptr("network"), + DefaultPrefixLen: utils.Ptr(int64(20)), + MaxPrefixLen: utils.Ptr(int64(22)), + MinPrefixLen: utils.Ptr(int64(18)), + }, + Name: utils.Ptr("name"), + }, + &[]iaas.NetworkRange{ + { + NetworkRangeId: utils.Ptr(testRangeId2), + Prefix: utils.Ptr("prefix-2"), + }, + { + NetworkRangeId: utils.Ptr(testRangeId3), + Prefix: utils.Ptr("prefix-3"), + }, + { + NetworkRangeId: utils.Ptr(testRangeId1), + Prefix: utils.Ptr("prefix-1"), + }, + }, + Model{ + Id: types.StringValue("oid,naid"), + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + Name: types.StringValue("name"), + DefaultNameservers: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("nameserver1"), + types.StringValue("nameserver2"), + }), + TransferNetwork: types.StringValue("network"), + DefaultPrefixLength: types.Int64Value(20), + MaxPrefixLength: types.Int64Value(22), + MinPrefixLength: types.Int64Value(18), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId1), + "prefix": types.StringValue("prefix-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId3), + "prefix": types.StringValue("prefix-3"), + }), + }), + }, + true, + }, + { + "default_nameservers_changed_outside_tf", + Model{ + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + DefaultNameservers: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("ns1"), + types.StringValue("ns2"), + }), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId1), + "prefix": types.StringValue("prefix-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + }), + }, + &iaas.NetworkArea{ + AreaId: utils.Ptr("naid"), + Ipv4: &iaas.NetworkAreaIPv4{ + DefaultNameservers: &[]string{ + "ns2", + "ns3", + }, + }, + }, + &[]iaas.NetworkRange{ + { + NetworkRangeId: utils.Ptr(testRangeId1), + Prefix: utils.Ptr("prefix-1"), + }, + { + NetworkRangeId: utils.Ptr(testRangeId2), + Prefix: utils.Ptr("prefix-2"), + }, + }, + Model{ + Id: types.StringValue("oid,naid"), + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + DefaultNameservers: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("ns2"), + types.StringValue("ns3"), + }), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId1), + "prefix": types.StringValue("prefix-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + }), + }, + true, + }, + { + "network_ranges_changed_outside_tf", + Model{ + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId1), + "prefix": types.StringValue("prefix-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + }), + }, + &iaas.NetworkArea{ + AreaId: utils.Ptr("naid"), + Ipv4: &iaas.NetworkAreaIPv4{}, + }, + &[]iaas.NetworkRange{ + { + NetworkRangeId: utils.Ptr(testRangeId2), + Prefix: utils.Ptr("prefix-2"), + }, + { + NetworkRangeId: utils.Ptr(testRangeId3), + Prefix: utils.Ptr("prefix-3"), + }, + }, + Model{ + Id: types.StringValue("oid,naid"), + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + DefaultNameservers: types.ListNull(types.StringType), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId2), + "prefix": types.StringValue("prefix-2"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringValue(testRangeId3), + "prefix": types.StringValue("prefix-3"), + }), + }), + }, + true, + }, + { + "nil_network_ranges_list", + Model{}, + &iaas.NetworkArea{}, + nil, + Model{}, + false, + }, + { + "response_nil_fail", + Model{}, + nil, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + OrganizationId: types.StringValue("oid"), + }, + &iaas.NetworkArea{}, + &[]iaas.NetworkRange{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, tt.ListNetworkRanges, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaas.CreateNetworkAreaPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + DefaultNameservers: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("ns1"), + types.StringValue("ns2"), + }), + NetworkRanges: types.ListValueMust(types.ObjectType{AttrTypes: networkRangeTypes}, []attr.Value{ + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringUnknown(), + "prefix": types.StringValue("pr-1"), + }), + types.ObjectValueMust(networkRangeTypes, map[string]attr.Value{ + "network_range_id": types.StringUnknown(), + "prefix": types.StringValue("pr-2"), + }), + }), + TransferNetwork: types.StringValue("network"), + DefaultPrefixLength: types.Int64Value(20), + MaxPrefixLength: types.Int64Value(22), + MinPrefixLength: types.Int64Value(18), + }, + &iaas.CreateNetworkAreaPayload{ + Name: utils.Ptr("name"), + AddressFamily: &iaas.CreateAreaAddressFamily{ + Ipv4: &iaas.CreateAreaIPv4{ + DefaultNameservers: &[]string{ + "ns1", + "ns2", + }, + NetworkRanges: &[]iaas.NetworkRange{ + { + Prefix: utils.Ptr("pr-1"), + }, + { + Prefix: utils.Ptr("pr-2"), + }, + }, + TransferNetwork: utils.Ptr("network"), + DefaultPrefixLen: utils.Ptr(int64(20)), + MaxPrefixLen: utils.Ptr(int64(22)), + MinPrefixLen: utils.Ptr(int64(18)), + }, + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaas.PartialUpdateNetworkAreaPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + DefaultNameservers: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("ns1"), + types.StringValue("ns2"), + }), + DefaultPrefixLength: types.Int64Value(22), + MaxPrefixLength: types.Int64Value(24), + MinPrefixLength: types.Int64Value(20), + }, + &iaas.PartialUpdateNetworkAreaPayload{ + Name: utils.Ptr("name"), + AddressFamily: &iaas.UpdateAreaAddressFamily{ + Ipv4: &iaas.UpdateAreaIPv4{ + DefaultNameservers: &[]string{ + "ns1", + "ns2", + }, + DefaultPrefixLen: utils.Ptr(int64(22)), + MaxPrefixLen: utils.Ptr(int64(24)), + MinPrefixLen: utils.Ptr(int64(20)), + }, + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestUpdateNetworkRanges(t *testing.T) { + getAllNetworkRangesResp := iaas.NetworkRangeListResponse{ + Items: &[]iaas.NetworkRange{ + { + Prefix: utils.Ptr("pr-1"), + NetworkRangeId: utils.Ptr(testRangeId1), + }, + { + Prefix: utils.Ptr("pr-2"), + NetworkRangeId: utils.Ptr(testRangeId2), + }, + { + Prefix: utils.Ptr("pr-3"), + NetworkRangeId: utils.Ptr(testRangeId3), + }, + { + Prefix: utils.Ptr("pr-2"), + NetworkRangeId: utils.Ptr(testRangeId2Repeated), + }, + }, + } + getAllNetworkRangesRespBytes, err := json.Marshal(getAllNetworkRangesResp) + if err != nil { + t.Fatalf("Failed to marshal get all network ranges response: %v", err) + } + + // This is the response used whenever an API returns a failure response + failureRespBytes := []byte("{\"message\": \"Something bad happened\"") + + tests := []struct { + description string + networkRanges []networkRange + ipv4 []iaas.NetworkRange + getAllNetworkRangesFails bool + createNetworkRangesFails bool + deleteNetworkRangesFails bool + isValid bool + expectedNetworkRangesStates map[string]bool // Keys are prefix; value is true if prefix should exist at the end, false if should be deleted + }{ + { + description: "no_changes", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId2), + Prefix: types.StringValue("pr-2"), + }, + { + NetworkRangeId: types.StringValue(testRangeId3), + Prefix: types.StringValue("pr-3"), + }, + }, + expectedNetworkRangesStates: map[string]bool{ + "pr-1": true, + "pr-2": true, + "pr-3": true, + }, + isValid: true, + }, + { + description: "create_network_ranges", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId2), + Prefix: types.StringValue("pr-2"), + }, + { + NetworkRangeId: types.StringValue(testRangeId3), + Prefix: types.StringValue("pr-3"), + }, + { + NetworkRangeId: types.StringValue(testRangeId4), + Prefix: types.StringValue("pr-4"), + }, + }, + expectedNetworkRangesStates: map[string]bool{ + "pr-1": true, + "pr-2": true, + "pr-3": true, + "pr-4": true, + }, + isValid: true, + }, + { + description: "delete_network_ranges", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId3), + Prefix: types.StringValue("pr-3"), + }, + }, + expectedNetworkRangesStates: map[string]bool{ + "pr-1": true, + "pr-2": false, + "pr-3": true, + }, + isValid: true, + }, + { + description: "multiple_changes", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId3), + Prefix: types.StringValue("pr-3"), + }, + { + NetworkRangeId: types.StringValue(testRangeId4), + Prefix: types.StringValue("pr-4"), + }, + { + NetworkRangeId: types.StringValue(testRangeId5), + Prefix: types.StringValue("pr-5"), + }, + }, + expectedNetworkRangesStates: map[string]bool{ + "pr-1": true, + "pr-2": false, + "pr-3": true, + "pr-4": true, + "pr-5": true, + }, + isValid: true, + }, + { + description: "multiple_changes_repetition", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId3), + Prefix: types.StringValue("pr-3"), + }, + { + NetworkRangeId: types.StringValue(testRangeId4), + Prefix: types.StringValue("pr-4"), + }, + { + NetworkRangeId: types.StringValue(testRangeId5), + Prefix: types.StringValue("pr-5"), + }, + { + NetworkRangeId: types.StringValue(testRangeId5), + Prefix: types.StringValue("pr-5"), + }, + }, + expectedNetworkRangesStates: map[string]bool{ + "pr-1": true, + "pr-2": false, + "pr-3": true, + "pr-4": true, + "pr-5": true, + }, + isValid: true, + }, + { + description: "multiple_changes_2", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId4), + Prefix: types.StringValue("pr-4"), + }, + { + NetworkRangeId: types.StringValue(testRangeId5), + Prefix: types.StringValue("pr-5"), + }, + }, + expectedNetworkRangesStates: map[string]bool{ + "pr-1": false, + "pr-2": false, + "pr-3": false, + "pr-4": true, + "pr-5": true, + }, + isValid: true, + }, + { + description: "multiple_changes_3", + networkRanges: []networkRange{}, + expectedNetworkRangesStates: map[string]bool{ + "pr-1": false, + "pr-2": false, + "pr-3": false, + }, + isValid: true, + }, + { + description: "get_fails", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId2), + Prefix: types.StringValue("pr-2"), + }, + { + NetworkRangeId: types.StringValue(testRangeId3), + Prefix: types.StringValue("pr-3"), + }, + }, + getAllNetworkRangesFails: true, + isValid: false, + }, + { + description: "create_fails_1", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId2), + Prefix: types.StringValue("pr-2"), + }, + { + NetworkRangeId: types.StringValue(testRangeId3), + Prefix: types.StringValue("pr-3"), + }, + { + NetworkRangeId: types.StringValue(testRangeId4), + Prefix: types.StringValue("pr-4"), + }, + }, + createNetworkRangesFails: true, + isValid: false, + }, + { + description: "create_fails_2", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId2), + Prefix: types.StringValue("pr-2"), + }, + }, + createNetworkRangesFails: true, + expectedNetworkRangesStates: map[string]bool{ + "pr-1": true, + "pr-2": true, + "pr-3": false, + }, + isValid: true, + }, + { + description: "delete_fails_1", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId2), + Prefix: types.StringValue("pr-2"), + }, + }, + deleteNetworkRangesFails: true, + isValid: false, + }, + { + description: "delete_fails_2", + networkRanges: []networkRange{ + { + NetworkRangeId: types.StringValue(testRangeId1), + Prefix: types.StringValue("pr-1"), + }, + { + NetworkRangeId: types.StringValue(testRangeId2), + Prefix: types.StringValue("pr-2"), + }, + { + NetworkRangeId: types.StringValue(testRangeId3), + Prefix: types.StringValue("pr-3"), + }, + { + NetworkRangeId: types.StringValue(testRangeId4), + Prefix: types.StringValue("pr-4"), + }, + }, + deleteNetworkRangesFails: true, + expectedNetworkRangesStates: map[string]bool{ + "pr-1": true, + "pr-2": true, + "pr-3": true, + "pr-4": true, + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + // Will be compared to tt.expectedNetworkRangesStates at the end + networkRangesStates := make(map[string]bool) + networkRangesStates["pr-1"] = true + networkRangesStates["pr-2"] = true + networkRangesStates["pr-3"] = true + + // Handler for getting all network ranges + getAllNetworkRangesHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if tt.getAllNetworkRangesFails { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write(failureRespBytes) + if err != nil { + t.Errorf("Get all network ranges handler: failed to write bad response: %v", err) + } + return + } + + _, err := w.Write(getAllNetworkRangesRespBytes) + if err != nil { + t.Errorf("Get all network ranges handler: failed to write response: %v", err) + } + }) + + // Handler for creating network range + createNetworkRangeHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decoder := json.NewDecoder(r.Body) + var payload iaas.CreateNetworkAreaRangePayload + err := decoder.Decode(&payload) + if err != nil { + t.Errorf("Create network range handler: failed to parse payload") + return + } + if payload.Ipv4 == nil { + t.Errorf("Create network range handler: nil Ipv4") + return + } + ipv4 := *payload.Ipv4 + + for _, networkRange := range ipv4 { + prefix := *networkRange.Prefix + if prefixExists, prefixWasCreated := networkRangesStates[prefix]; prefixWasCreated && prefixExists { + t.Errorf("Create network range handler: attempted to create range '%v' that already exists", *payload.Ipv4) + return + } + w.Header().Set("Content-Type", "application/json") + if tt.createNetworkRangesFails { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write(failureRespBytes) + if err != nil { + t.Errorf("Create network ranges handler: failed to write bad response: %v", err) + } + return + } + + resp := iaas.NetworkRange{ + Prefix: utils.Ptr("prefix"), + NetworkRangeId: utils.Ptr("id-range"), + } + respBytes, err := json.Marshal(resp) + if err != nil { + t.Errorf("Create network range handler: failed to marshal response: %v", err) + return + } + _, err = w.Write(respBytes) + if err != nil { + t.Errorf("Create network range handler: failed to write response: %v", err) + } + networkRangesStates[prefix] = true + } + }) + + // Handler for deleting Network range + deleteNetworkRangeHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + networkRangeId, ok := vars["networkRangeId"] + if !ok { + t.Errorf("Delete network range handler: no range ID") + return + } + + var prefix string + for _, rangeItem := range *getAllNetworkRangesResp.Items { + if *rangeItem.NetworkRangeId == networkRangeId { + prefix = *rangeItem.Prefix + } + } + prefixExists, prefixWasCreated := networkRangesStates[prefix] + if !prefixWasCreated { + t.Errorf("Delete network range handler: attempted to delete range '%v' that wasn't created", prefix) + return + } + if prefixWasCreated && !prefixExists { + t.Errorf("Delete network range handler: attempted to delete range '%v' that was already deleted", prefix) + return + } + + w.Header().Set("Content-Type", "application/json") + if tt.deleteNetworkRangesFails { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write(failureRespBytes) + if err != nil { + t.Errorf("Delete network range handler: failed to write bad response: %v", err) + } + return + } + + _, err = w.Write([]byte("{}")) + if err != nil { + t.Errorf("Delete network range handler: failed to write response: %v", err) + } + networkRangesStates[prefix] = false + }) + + // Setup server and client + router := mux.NewRouter() + router.HandleFunc("/v1beta1/organizations/{organizationId}/network-areas/{areaId}/network-ranges", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + getAllNetworkRangesHandler(w, r) + } else if r.Method == "POST" { + createNetworkRangeHandler(w, r) + } + }) + router.HandleFunc("/v1beta1/organizations/{organizationId}/network-areas/{areaId}/network-ranges/{networkRangeId}", deleteNetworkRangeHandler) + mockedServer := httptest.NewServer(router) + defer mockedServer.Close() + client, err := iaas.NewAPIClient( + config.WithEndpoint(mockedServer.URL), + config.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("Failed to initialize client: %v", err) + } + + // Run test + err = updateNetworkRanges(context.Background(), testOrganizationId, testAreaId, tt.networkRanges, client) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(networkRangesStates, tt.expectedNetworkRangesStates) + if diff != "" { + t.Fatalf("Network range states do not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/iaas/networkarearoute/datasource.go b/stackit/internal/services/iaas/networkarearoute/datasource.go new file mode 100644 index 00000000..108c07c8 --- /dev/null +++ b/stackit/internal/services/iaas/networkarearoute/datasource.go @@ -0,0 +1,171 @@ +package networkarearoute + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// scheduleDataSourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var networkAreaRouteDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &networkAreaRouteDataSource{} +) + +// NewNetworkAreaRouteDataSource is a helper function to simplify the provider implementation. +func NewNetworkAreaRouteDataSource() datasource.DataSource { + return &networkAreaRouteDataSource{} +} + +// networkDataSource is the data source implementation. +type networkAreaRouteDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *networkAreaRouteDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_network_area_route" +} + +func (d *networkAreaRouteDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + var apiClient *iaas.APIClient + var err error + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !networkAreaRouteDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_network_area_route", "data source") + if resp.Diagnostics.HasError() { + return + } + networkAreaRouteDataSourceBetaCheckDone = true + } + + if providerData.IaaSCustomEndpoint != "" { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err)) + return + } + + d.client = apiClient + tflog.Info(ctx, "IaaS client configured") +} + +// Schema defines the schema for the data source. +func (d *networkAreaRouteDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Network area route data resource schema. Must have a `region` specified in the provider configuration.", + MarkdownDescription: features.AddBetaDescription("Network area route data source schema. Must have a `region` specified in the provider configuration."), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal data source ID. It is structured as \"`organization_id`,`network_area_id`,`network_area_route_id`\".", + Computed: true, + }, + "organization_id": schema.StringAttribute{ + Description: "STACKIT organization ID to which the network area is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_area_id": schema.StringAttribute{ + Description: "The network area ID to which the network area route is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_area_route_id": schema.StringAttribute{ + Description: "The network area route ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "next_hop": schema.StringAttribute{ + Description: "The IP address of the routing system, that will route the prefix configured. Should be a valid IPv4 address.", + Computed: true, + }, + "prefix": schema.StringAttribute{ + Description: "The network, that is reachable though the Next Hop. Should use CIDR notation.", + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *networkAreaRouteDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + organizationId := model.OrganizationId.ValueString() + networkAreaId := model.NetworkAreaId.ValueString() + networkAreaRouteId := model.NetworkAreaRouteId.ValueString() + ctx = tflog.SetField(ctx, "organization_id", organizationId) + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + ctx = tflog.SetField(ctx, "network_area_route_id", networkAreaRouteId) + + networkAreaRouteResp, err := d.client.GetNetworkAreaRoute(ctx, organizationId, networkAreaId, networkAreaRouteId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(networkAreaRouteResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network area route read") +} diff --git a/stackit/internal/services/iaas/networkarearoute/resource.go b/stackit/internal/services/iaas/networkarearoute/resource.go new file mode 100644 index 00000000..7935ee2e --- /dev/null +++ b/stackit/internal/services/iaas/networkarearoute/resource.go @@ -0,0 +1,383 @@ +package networkarearoute + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &networkAreaRouteResource{} + _ resource.ResourceWithConfigure = &networkAreaRouteResource{} + _ resource.ResourceWithImportState = &networkAreaRouteResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + OrganizationId types.String `tfsdk:"organization_id"` + NetworkAreaId types.String `tfsdk:"network_area_id"` + NetworkAreaRouteId types.String `tfsdk:"network_area_route_id"` + NextHop types.String `tfsdk:"next_hop"` + Prefix types.String `tfsdk:"prefix"` +} + +// NewNetworkAreaRouteResource is a helper function to simplify the provider implementation. +func NewNetworkAreaRouteResource() resource.Resource { + return &networkAreaRouteResource{} +} + +// networkResource is the resource implementation. +type networkAreaRouteResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *networkAreaRouteResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_network_area_route" +} + +// Configure adds the provider configured client to the resource. +func (r *networkAreaRouteResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_network_area_route", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaas.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaas.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "IaaS client configured") +} + +// Schema defines the schema for the resource. +func (r *networkAreaRouteResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Network area route resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`,`network_area_route_id`\".", + MarkdownDescription: features.AddBetaDescription("Network area route resource schema. Must have a `region` specified in the provider configuration."), + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "organization_id": schema.StringAttribute{ + Description: "STACKIT organization ID to which the network area is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_area_id": schema.StringAttribute{ + Description: "The network area ID to which the network area route is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_area_route_id": schema.StringAttribute{ + Description: "The network area route ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "next_hop": schema.StringAttribute{ + Description: "The IP address of the routing system, that will route the prefix configured. Should be a valid IPv4 address.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.IP(), + }, + }, + "prefix": schema.StringAttribute{ + Description: "The network, that is reachable though the Next Hop. Should use CIDR notation.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.CIDR(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *networkAreaRouteResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + organizationId := model.OrganizationId.ValueString() + ctx = tflog.SetField(ctx, "organization_id", organizationId) + networkAreaId := model.NetworkAreaId.ValueString() + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + + // Generate API request body from model + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new network area route + routes, err := r.client.CreateNetworkAreaRoute(ctx, organizationId, networkAreaId).CreateNetworkAreaRoutePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route", fmt.Sprintf("Calling API: %v", err)) + return + } + if routes.Items == nil || len(*routes.Items) == 0 { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route.", "Empty response from API") + return + } + + if len(*routes.Items) != 1 { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route.", "New static route not found or more than 1 route found in API response.") + return + } + + // Gets the route ID from the first element, routes.Items[0] + routeItems := *routes.Items + route := routeItems[0] + routeId := *route.RouteId + + ctx = tflog.SetField(ctx, "network_area_route_id", routeId) + + // Map response body to schema + err = mapFields(&route, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route.", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network area route created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *networkAreaRouteResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + organizationId := model.OrganizationId.ValueString() + networkAreaId := model.NetworkAreaId.ValueString() + networkAreaRouteId := model.NetworkAreaRouteId.ValueString() + ctx = tflog.SetField(ctx, "organization_id", organizationId) + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + ctx = tflog.SetField(ctx, "network_area_route_id", networkAreaRouteId) + + networkAreaRouteResp, err := r.client.GetNetworkAreaRoute(ctx, organizationId, networkAreaId, networkAreaRouteId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route.", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(networkAreaRouteResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network area route read") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *networkAreaRouteResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + organizationId := model.OrganizationId.ValueString() + networkAreaId := model.NetworkAreaId.ValueString() + networkAreaRouteId := model.NetworkAreaRouteId.ValueString() + ctx = tflog.SetField(ctx, "organization_id", organizationId) + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + ctx = tflog.SetField(ctx, "network_area_route_id", networkAreaRouteId) + + // Delete existing network + err := r.client.DeleteNetworkAreaRoute(ctx, organizationId, networkAreaId, networkAreaRouteId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area route", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "Network area route deleted") +} + +func (r *networkAreaRouteResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update shouldn't be called + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area route", "Network area route can't be updated") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: organization_id,network_aread_id,network_area_route_id +func (r *networkAreaRouteResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing network area route", + fmt.Sprintf("Expected import identifier with format: [organization_id],[network_area_id],[network_area_route_id] Got: %q", req.ID), + ) + return + } + + organizationId := idParts[0] + networkAreaId := idParts[1] + networkAreaRouteId := idParts[2] + ctx = tflog.SetField(ctx, "organization_id", organizationId) + ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + ctx = tflog.SetField(ctx, "network_area_route_id", networkAreaRouteId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), organizationId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_area_id"), networkAreaId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_area_route_id"), networkAreaRouteId)...) + tflog.Info(ctx, "Network area route state imported") +} + +func mapFields(networkAreaRoute *iaas.Route, model *Model) error { + if networkAreaRoute == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var networkAreaRouteId string + if model.NetworkAreaRouteId.ValueString() != "" { + networkAreaRouteId = model.NetworkAreaRouteId.ValueString() + } else if networkAreaRoute.RouteId != nil { + networkAreaRouteId = *networkAreaRoute.RouteId + } else { + return fmt.Errorf("network area route id not present") + } + + idParts := []string{ + model.OrganizationId.ValueString(), + model.NetworkAreaId.ValueString(), + networkAreaRouteId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + model.NetworkAreaRouteId = types.StringValue(networkAreaRouteId) + model.NextHop = types.StringPointerValue(networkAreaRoute.Nexthop) + model.Prefix = types.StringPointerValue(networkAreaRoute.Prefix) + return nil +} + +func toCreatePayload(model *Model) (*iaas.CreateNetworkAreaRoutePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &iaas.CreateNetworkAreaRoutePayload{ + Ipv4: &[]iaas.Route{ + { + Prefix: conversion.StringValueToPointer(model.Prefix), + Nexthop: conversion.StringValueToPointer(model.NextHop), + }, + }, + }, nil +} diff --git a/stackit/internal/services/iaas/networkarearoute/resource_test.go b/stackit/internal/services/iaas/networkarearoute/resource_test.go new file mode 100644 index 00000000..c3fd2c4a --- /dev/null +++ b/stackit/internal/services/iaas/networkarearoute/resource_test.go @@ -0,0 +1,147 @@ +package networkarearoute + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaas.Route + expected Model + isValid bool + }{ + { + "id_ok", + Model{ + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + NetworkAreaRouteId: types.StringValue("narid"), + }, + &iaas.Route{}, + Model{ + Id: types.StringValue("oid,naid,narid"), + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + NetworkAreaRouteId: types.StringValue("narid"), + Prefix: types.StringNull(), + NextHop: types.StringNull(), + }, + true, + }, + { + "values_ok", + Model{ + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + NetworkAreaRouteId: types.StringValue("narid"), + }, + &iaas.Route{ + Prefix: utils.Ptr("prefix"), + Nexthop: utils.Ptr("hop"), + }, + Model{ + Id: types.StringValue("oid,naid,narid"), + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + NetworkAreaRouteId: types.StringValue("narid"), + Prefix: types.StringValue("prefix"), + NextHop: types.StringValue("hop"), + }, + true, + }, + { + "response_fields_nil_fail", + Model{}, + &iaas.Route{ + Prefix: nil, + Nexthop: nil, + }, + Model{}, + false, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + OrganizationId: types.StringValue("oid"), + NetworkAreaId: types.StringValue("naid"), + }, + &iaas.Route{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaas.CreateNetworkAreaRoutePayload + isValid bool + }{ + { + description: "default_ok", + input: &Model{ + Prefix: types.StringValue("prefix"), + NextHop: types.StringValue("hop"), + }, + expected: &iaas.CreateNetworkAreaRoutePayload{ + Ipv4: &[]iaas.Route{ + { + Prefix: utils.Ptr("prefix"), + Nexthop: utils.Ptr("hop"), + }, + }, + }, + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 5107b836..b6e35f0d 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -28,6 +28,8 @@ var ( "stackit": providerserver.NewProtocol6WithError(stackit.New("test-version")()), } + // OrganizationId is the id of organization used for tests + OrganizationId = os.Getenv("TF_ACC_ORGANIZATION_ID") // ProjectId is the id of project used for tests ProjectId = os.Getenv("TF_ACC_PROJECT_ID") // ServerId is the id of a server used for some tests diff --git a/stackit/provider.go b/stackit/provider.go index c8ebfe34..7d49c400 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -15,6 +15,8 @@ import ( dnsRecordSet "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/recordset" dnsZone "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/zone" iaasNetwork "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/network" + iaasNetworkArea "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarea" + iaasNetworkAreaRoute "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarearoute" loadBalancerCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/credential" loadBalancer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/loadbalancer" loadBalancerObservabilityCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/observability-credential" @@ -401,6 +403,8 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource dnsZone.NewZoneDataSource, dnsRecordSet.NewRecordSetDataSource, iaasNetwork.NewNetworkDataSource, + iaasNetworkArea.NewNetworkAreaDataSource, + iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource, loadBalancer.NewLoadBalancerDataSource, logMeInstance.NewInstanceDataSource, logMeCredential.NewCredentialDataSource, @@ -443,6 +447,8 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { dnsZone.NewZoneResource, dnsRecordSet.NewRecordSetResource, iaasNetwork.NewNetworkResource, + iaasNetworkArea.NewNetworkAreaResource, + iaasNetworkAreaRoute.NewNetworkAreaRouteResource, loadBalancer.NewLoadBalancerResource, loadBalancerCredential.NewCredentialResource, loadBalancerObservabilityCredential.NewObservabilityCredentialResource, diff --git a/templates/guides/opting_into_beta_resources.md.tmpl b/templates/guides/opting_into_beta_resources.md.tmpl index 6378e1c8..7512647e 100644 --- a/templates/guides/opting_into_beta_resources.md.tmpl +++ b/templates/guides/opting_into_beta_resources.md.tmpl @@ -37,8 +37,11 @@ export STACKIT_TF_ENABLE_BETA_RESOURCES=true ## Listing Beta Resources - [`stackit_server_backup_schedule`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/server_backup_schedule) +- [`stackit_network_area`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/network_area) +- [`stackit_network_area_route`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/network_area_route) ## Listing Beta Data Sources - [`stackit_server_backup_schedule`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/server_backup_schedule) -- [`stackit_server_backup_schedules`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/server_backup_schedules) \ No newline at end of file +- [`stackit_server_backup_schedules`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/server_backup_schedules) +- [`stackit_network_area_route`](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/data-sources/network_area_route) \ No newline at end of file