From 93fe2fe89f82e86b4f6730e1abad544d5d8a7da6 Mon Sep 17 00:00:00 2001 From: Vicente Pinto Date: Fri, 18 Oct 2024 16:37:41 +0100 Subject: [PATCH] IaaS Release (#543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * IaaS Volume (#541) * Onboard IaaS Volume * Labels mapping * Add acceptance test * Remove source field * Fix lint * Add examples and docs * Fix lint * Fix lint * Fix lint * Volume source field (#542) * Onboard IaaS Volume * Labels mapping * Add acceptance test * Remove source field * Fix lint * Add examples and docs * Fix lint * Fix lint * Fix lint * Add source field supoort * Fix labels and source mapping * Remove unecessary source mapping * Move methods to conversion pkg * Revert change * Update stackit/internal/services/iaas/volume/datasource.go Co-authored-by: João Palet * Update stackit/internal/services/iaas/volume/resource.go Co-authored-by: João Palet * Update stackit/internal/services/iaas/volume/resource.go Co-authored-by: João Palet * Update stackit/internal/services/iaas/volume/resource.go Co-authored-by: João Palet * Changes after review * Change after revie --------- Co-authored-by: João Palet * Onboard IaaS security groups (#545) * onboard iaas security group * add examples and generate docs * fix linter issues * fix deletion * Update stackit/internal/services/iaas/securitygroup/resource.go Co-authored-by: Vicente Pinto * rename data source example file * update docs * remove field * remove field * remove plan modifier from the name field * refactor labels in mapFields * change function from utils to conversion * remove rules from the security group * update docs * add security group acceptance test * add plan modifiers to stateful field * sort imports * change stateful description --------- Co-authored-by: Gökçe Gök Klingel Co-authored-by: Vicente Pinto * IaaS Server baseline configuration (#546) * Server resource schema * Implemente CRUD methods and unit testsg * Bug fixes * Bug fix * Make variable private * Remove delete_on_termination and update descriptions * Add security_group field to initial networking * Add examples and acc test * Generate docs * Fix lint * Fix lint issue * Fix unit test * Update desc * Gen docs * Onboard IaaS network interface (#544) * implement network interface * handle labels * add CIDR validation * fix linter issues and generate docs * remove computed from the allowed addresses and fix the conditions * Update stackit/internal/services/iaas/networkinterface/resource.go Co-authored-by: Vicente Pinto * Update stackit/internal/services/iaas/networkinterface/datasource.go Co-authored-by: Vicente Pinto * apply code review changes * remove status from schema * remove unnecessary GET call * Update stackit/internal/services/iaas/networkinterface/resource.go Co-authored-by: Vicente Pinto * Update stackit/internal/services/iaas/networkinterface/resource.go Co-authored-by: Vicente Pinto * rename nic_security to security * add beta markdown description * use existing validateIP function * use utils function for the options listing * refactor labels * change function from utils to conversion * make allowed addresses a list of strings * add acceptance test for network interfaces * fix acceptance test * rename security_groups as security_group_ids * extend descriptions * fix acc test --------- Co-authored-by: Gökçe Gök Klingel Co-authored-by: Vicente Pinto * rename volume data source example (#552) Co-authored-by: Gökçe Gök Klingel * add requires replace to ipv4 and ipv6 fields (#549) Co-authored-by: Gökçe Gök Klingel Co-authored-by: Vicente Pinto * Server resource improvements (#548) * Improvements to server resource * Fix example * Remove useStateForUnknown * Update SDK modules * Update iaasalpha moduel (#555) * Remove initial networking field (#556) * Server attachment resources (#557) * Server attachemnt resources * Add examples * Update volume datasource example * Fix linting issues * Fix linting * Fix examples formatting * Update go.mod * Revert iaas to v0.11 * Onboard iaas public ip (#551) * onboard public ip * onboard public ip * add public ip acceptance test * Update examples/data-sources/stackit_public_ip/data-source.tf Co-authored-by: Vicente Pinto * add plan modifier to IP * change type in the volume data source * add network_interface field to public ip resource * rename network_interface to network_interface_id * remove obsolete checks * extend unit tests * add network_interface_id in example * extend unit test * extend acceptance test * sort imports --------- Co-authored-by: Vicente Pinto * Add labels to network, network are and network area route resources (#559) * Fix network_interface example * Extend network, network area and network area route with labels * Revert iaas to v0.11.0 --------- Co-authored-by: GokceGK <161626272+GokceGK@users.noreply.github.com> * Onboard iaas security group rule (#553) * onboard security group rule * add security group rule to acceptance test * change type in examples * fix acc test issues * extend example with objects * remove obsolete field from acceptance test * remove unnecessary plan modifier * adapt schema fields * adapt schema fields * add requires replace to all fields * extend descriptions with protocol limitations * rename subfield protocol to number * add requires replace to objects * make icmp_parameters fields required * add empty field checks for nested objects * make max and min fields required in the port_range object * make number field computed in the protocol object * add UseStateForUnknown in protocol number * remove obsolete unit test * add checks for empty protocol and adapt unit test * add atLeastOneOf validation in protocol fields * fix linter issues * Add project existence check before deleting SNA (#561) * add project list check and error in network area deletion * Update stackit/internal/services/iaas/networkarea/resource.go Co-authored-by: Vicente Pinto --------- Co-authored-by: Vicente Pinto * Example server use cases and other fixes (#560) * Add example usage to server resource * Update examples * Fix beta warning * Update docs and examples * Remove size from example * Fix server description, fix security group rule error message * Other fixes * remove field from datasource --------- Co-authored-by: GokceGK <161626272+GokceGK@users.noreply.github.com> * Security group rule fixes (#562) * Add example usage to server resource * Update examples * Fix beta warning * Update docs and examples * Remove size from example * Fix server description, fix security group rule error message * Other fixes * Fixes to sec group rule * Fix lint * Change after review --------- Co-authored-by: GokceGK <161626272+GokceGK@users.noreply.github.com> * Fix server example (#565) * Fix server example * Fixes to examples, add CIDR validation to nic * Migrate iaasalpha to iaas (#568) * Migrate iaasalpha to iaas * Fix lint * Update example * Improvements to security group rule (#569) * Improvements to security group rule * Fix lint * Fix example and remove computed from description * Fix formatting * Update description --------- Co-authored-by: João Palet Co-authored-by: GokceGK <161626272+GokceGK@users.noreply.github.com> Co-authored-by: Gökçe Gök Klingel --- docs/data-sources/network.md | 1 + docs/data-sources/network_area.md | 1 + docs/data-sources/network_area_route.md | 1 + docs/data-sources/network_interface.md | 46 + docs/data-sources/public_ip.md | 38 + docs/data-sources/security_group.md | 39 + docs/data-sources/security_group_rule.md | 71 ++ docs/data-sources/server.md | 50 + docs/data-sources/volume.md | 51 + docs/resources/loadbalancer.md | 28 +- docs/resources/network.md | 4 + docs/resources/network_area.md | 15 +- docs/resources/network_area_route.md | 20 +- docs/resources/network_interface.md | 50 + docs/resources/public_ip.md | 44 + docs/resources/security_group.md | 45 + docs/resources/security_group_rule.md | 81 ++ docs/resources/server.md | 374 ++++++ .../server_network_interface_attach.md | 37 + .../server_service_account_attach.md | 37 + docs/resources/server_volume_attach.md | 37 + docs/resources/sqlserverflex_user.md | 4 +- docs/resources/volume.md | 59 + .../stackit_network_interface/data-source.tf | 5 + .../stackit_public_ip/data-source.tf | 4 + .../stackit_security_group/data-source.tf | 4 + .../data-source.tf | 5 + .../data-sources/stackit_server/datasource.tf | 4 + .../stackit_volume/data-source.tf | 4 + .../resources/stackit_network/resource.tf | 3 + .../stackit_network_area/resource.tf | 7 +- .../stackit_network_area_route/resource.tf | 9 +- .../stackit_network_interface/resource.tf | 6 + .../resources/stackit_public_ip/resource.tf | 7 + .../stackit_security_group/resource.tf | 7 + .../stackit_security_group_rule/resource.tf | 12 + .../resource.tf | 5 + .../resource.tf | 5 + .../stackit_server_volume_attach/resource.tf | 5 + examples/resources/stackit_volume/resource.tf | 9 + go.mod | 2 +- go.sum | 4 +- stackit/internal/conversion/conversion.go | 34 + .../internal/conversion/conversion_test.go | 137 +++ .../internal/services/iaas/iaas_acc_test.go | 1079 +++++++++++++++-- .../services/iaas/network/datasource.go | 5 + .../services/iaas/network/resource.go | 50 +- .../services/iaas/network/resource_test.go | 25 +- .../services/iaas/networkarea/datasource.go | 5 + .../services/iaas/networkarea/resource.go | 66 +- .../iaas/networkarea/resource_test.go | 24 +- .../iaas/networkarearoute/datasource.go | 8 +- .../iaas/networkarearoute/resource.go | 50 +- .../iaas/networkarearoute/resource_test.go | 19 +- .../iaas/networkinterface/datasource.go | 206 ++++ .../iaas/networkinterface/resource.go | 636 ++++++++++ .../iaas/networkinterface/resource_test.go | 273 +++++ .../iaas/networkinterfaceattach/resource.go | 303 +++++ .../services/iaas/publicip/datasource.go | 171 +++ .../services/iaas/publicip/resource.go | 431 +++++++ .../services/iaas/publicip/resource_test.go | 264 ++++ .../services/iaas/securitygroup/datasource.go | 171 +++ .../services/iaas/securitygroup/resource.go | 451 +++++++ .../iaas/securitygroup/resource_test.go | 218 ++++ .../iaas/securitygrouprule/datasource.go | 228 ++++ .../iaas/securitygrouprule/planmodifier.go | 93 ++ .../iaas/securitygrouprule/resource.go | 777 ++++++++++++ .../iaas/securitygrouprule/resource_test.go | 305 +++++ .../internal/services/iaas/server/const.go | 167 +++ .../services/iaas/server/datasource.go | 224 ++++ .../internal/services/iaas/server/resource.go | 682 +++++++++++ .../services/iaas/server/resource_test.go | 269 ++++ .../iaas/serviceaccountattach/resource.go | 299 +++++ .../services/iaas/volume/datasource.go | 202 +++ .../internal/services/iaas/volume/resource.go | 608 ++++++++++ .../services/iaas/volume/resource_test.go | 253 ++++ .../services/iaas/volumeattach/resource.go | 305 +++++ .../services/sqlserverflex/user/resource.go | 2 +- stackit/internal/testutil/testutil.go | 5 + stackit/provider.go | 24 + 80 files changed, 10148 insertions(+), 161 deletions(-) create mode 100644 docs/data-sources/network_interface.md create mode 100644 docs/data-sources/public_ip.md create mode 100644 docs/data-sources/security_group.md create mode 100644 docs/data-sources/security_group_rule.md create mode 100644 docs/data-sources/server.md create mode 100644 docs/data-sources/volume.md create mode 100644 docs/resources/network_interface.md create mode 100644 docs/resources/public_ip.md create mode 100644 docs/resources/security_group.md create mode 100644 docs/resources/security_group_rule.md create mode 100644 docs/resources/server.md create mode 100644 docs/resources/server_network_interface_attach.md create mode 100644 docs/resources/server_service_account_attach.md create mode 100644 docs/resources/server_volume_attach.md create mode 100644 docs/resources/volume.md create mode 100644 examples/data-sources/stackit_network_interface/data-source.tf create mode 100644 examples/data-sources/stackit_public_ip/data-source.tf create mode 100644 examples/data-sources/stackit_security_group/data-source.tf create mode 100644 examples/data-sources/stackit_security_group_rule/data-source.tf create mode 100644 examples/data-sources/stackit_server/datasource.tf create mode 100644 examples/data-sources/stackit_volume/data-source.tf create mode 100644 examples/resources/stackit_network_interface/resource.tf create mode 100644 examples/resources/stackit_public_ip/resource.tf create mode 100644 examples/resources/stackit_security_group/resource.tf create mode 100644 examples/resources/stackit_security_group_rule/resource.tf create mode 100644 examples/resources/stackit_server_network_interface_attach/resource.tf create mode 100644 examples/resources/stackit_server_service_account_attach/resource.tf create mode 100644 examples/resources/stackit_server_volume_attach/resource.tf create mode 100644 examples/resources/stackit_volume/resource.tf create mode 100644 stackit/internal/services/iaas/networkinterface/datasource.go create mode 100644 stackit/internal/services/iaas/networkinterface/resource.go create mode 100644 stackit/internal/services/iaas/networkinterface/resource_test.go create mode 100644 stackit/internal/services/iaas/networkinterfaceattach/resource.go create mode 100644 stackit/internal/services/iaas/publicip/datasource.go create mode 100644 stackit/internal/services/iaas/publicip/resource.go create mode 100644 stackit/internal/services/iaas/publicip/resource_test.go create mode 100644 stackit/internal/services/iaas/securitygroup/datasource.go create mode 100644 stackit/internal/services/iaas/securitygroup/resource.go create mode 100644 stackit/internal/services/iaas/securitygroup/resource_test.go create mode 100644 stackit/internal/services/iaas/securitygrouprule/datasource.go create mode 100644 stackit/internal/services/iaas/securitygrouprule/planmodifier.go create mode 100644 stackit/internal/services/iaas/securitygrouprule/resource.go create mode 100644 stackit/internal/services/iaas/securitygrouprule/resource_test.go create mode 100644 stackit/internal/services/iaas/server/const.go create mode 100644 stackit/internal/services/iaas/server/datasource.go create mode 100644 stackit/internal/services/iaas/server/resource.go create mode 100644 stackit/internal/services/iaas/server/resource_test.go create mode 100644 stackit/internal/services/iaas/serviceaccountattach/resource.go create mode 100644 stackit/internal/services/iaas/volume/datasource.go create mode 100644 stackit/internal/services/iaas/volume/resource.go create mode 100644 stackit/internal/services/iaas/volume/resource_test.go create mode 100644 stackit/internal/services/iaas/volumeattach/resource.go diff --git a/docs/data-sources/network.md b/docs/data-sources/network.md index 64adbb0f..16c3665d 100644 --- a/docs/data-sources/network.md +++ b/docs/data-sources/network.md @@ -31,6 +31,7 @@ data "stackit_network" "example" { - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`network_id`". - `ipv4_prefix_length` (Number) The IPv4 prefix length of the network. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container - `name` (String) The name of the network. - `nameservers` (List of String) The nameservers of the network. - `prefixes` (List of String) The prefixes of the network. diff --git a/docs/data-sources/network_area.md b/docs/data-sources/network_area.md index 4b9d8eda..9b092aa5 100644 --- a/docs/data-sources/network_area.md +++ b/docs/data-sources/network_area.md @@ -35,6 +35,7 @@ data "stackit_network_area" "example" { - `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`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container - `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. diff --git a/docs/data-sources/network_area_route.md b/docs/data-sources/network_area_route.md index fc66c505..f2b03e7d 100644 --- a/docs/data-sources/network_area_route.md +++ b/docs/data-sources/network_area_route.md @@ -35,5 +35,6 @@ data "stackit_network_area_route" "example" { ### Read-Only - `id` (String) Terraform's internal data source ID. It is structured as "`organization_id`,`network_area_id`,`network_area_route_id`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container - `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/data-sources/network_interface.md b/docs/data-sources/network_interface.md new file mode 100644 index 00000000..c62e75ec --- /dev/null +++ b/docs/data-sources/network_interface.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_network_interface Data Source - stackit" +subcategory: "" +description: |- + Network interface 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_interface (Data Source) + +Network interface 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_interface" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `network_id` (String) The network ID to which the network interface is associated. +- `network_interface_id` (String) The network interface ID. +- `project_id` (String) STACKIT project ID to which the network interface is associated. + +### Read-Only + +- `allowed_addresses` (List of String) The list of CIDR (Classless Inter-Domain Routing) notations. +- `device` (String) The device UUID of the network interface. +- `id` (String) Terraform's internal data source ID. It is structured as "`project_id`,`network_id`,`network_interface_id`". +- `ipv4` (String) The IPv4 address. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a network interface. +- `mac` (String) The MAC address of network interface. +- `name` (String) The name of the network interface. +- `security` (Boolean) The Network Interface Security. If set to false, then no security groups will apply to this network interface. +- `security_group_ids` (List of String) The list of security group UUIDs. If security is set to false, setting this field will lead to an error. +- `type` (String) Type of network interface. Some of the possible values are: Supported values are: `server`, `metadata`, `gateway`. diff --git a/docs/data-sources/public_ip.md b/docs/data-sources/public_ip.md new file mode 100644 index 00000000..fb4010b7 --- /dev/null +++ b/docs/data-sources/public_ip.md @@ -0,0 +1,38 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_public_ip Data Source - stackit" +subcategory: "" +description: |- + Volume 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. +--- + +# stackit_public_ip (Data Source) + +Volume 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. + +## Example Usage + +```terraform +data "stackit_public_ip" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + public_ip_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the public IP is associated. +- `public_ip_id` (String) The public IP ID. + +### Read-Only + +- `id` (String) Terraform's internal datasource ID. It is structured as "`project_id`,`public_ip_id`". +- `ip` (String) The IP address. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `network_interface_id` (String) Associates the public IP with a network interface or a virtual IP (ID). diff --git a/docs/data-sources/security_group.md b/docs/data-sources/security_group.md new file mode 100644 index 00000000..f8622cea --- /dev/null +++ b/docs/data-sources/security_group.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_security_group Data Source - stackit" +subcategory: "" +description: |- + Security group 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_security_group (Data Source) + +Security group 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_security_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the security group is associated. +- `security_group_id` (String) The security group ID. + +### Read-Only + +- `description` (String) The description of the security group. +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`security_group_id`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `name` (String) The name of the security group. +- `stateful` (Boolean) Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server. diff --git a/docs/data-sources/security_group_rule.md b/docs/data-sources/security_group_rule.md new file mode 100644 index 00000000..4d14d944 --- /dev/null +++ b/docs/data-sources/security_group_rule.md @@ -0,0 +1,71 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_security_group_rule Data Source - stackit" +subcategory: "" +description: |- + Security group 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_security_group_rule (Data Source) + +Security group 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_security_group_rule" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_rule_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the security group rule is associated. +- `security_group_id` (String) The security group ID. +- `security_group_rule_id` (String) The security group rule ID. + +### Read-Only + +- `description` (String) The description of the security group rule. +- `direction` (String) The direction of the traffic which the rule should match. Some of the possible values are: Supported values are: `ingress`, `egress`. +- `ether_type` (String) The ethertype which the rule should match. +- `icmp_parameters` (Attributes) ICMP Parameters. (see [below for nested schema](#nestedatt--icmp_parameters)) +- `id` (String) Terraform's internal datasource ID. It is structured as "`project_id`,`security_group_id`,`security_group_rule_id`". +- `ip_range` (String) The remote IP range which the rule should match. +- `port_range` (Attributes) The range of ports. (see [below for nested schema](#nestedatt--port_range)) +- `protocol` (Attributes) The internet protocol which the rule should match. (see [below for nested schema](#nestedatt--protocol)) +- `remote_security_group_id` (String) The remote security group which the rule should match. + + +### Nested Schema for `icmp_parameters` + +Read-Only: + +- `code` (Number) ICMP code. Can be set if the protocol is ICMP. +- `type` (Number) ICMP type. Can be set if the protocol is ICMP. + + + +### Nested Schema for `port_range` + +Read-Only: + +- `max` (Number) The maximum port number. Should be greater or equal to the minimum. +- `min` (Number) The minimum port number. Should be less or equal to the minimum. + + + +### Nested Schema for `protocol` + +Read-Only: + +- `name` (String) The protocol name which the rule should match. +- `number` (Number) The protocol number which the rule should match. diff --git a/docs/data-sources/server.md b/docs/data-sources/server.md new file mode 100644 index 00000000..36a7f415 --- /dev/null +++ b/docs/data-sources/server.md @@ -0,0 +1,50 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_server Data Source - stackit" +subcategory: "" +description: |- + Server 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_server (Data Source) + +Server 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. + + + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the server is associated. +- `server_id` (String) The server ID. + +### Read-Only + +- `affinity_group` (String) The affinity group the server is assigned to. +- `availability_zone` (String) The availability zone of the server. +- `boot_volume` (Attributes) The boot volume for the server (see [below for nested schema](#nestedatt--boot_volume)) +- `created_at` (String) Date-time when the server was created +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`". +- `image_id` (String) The image ID to be used for an ephemeral disk on the server. +- `keypair_name` (String) The name of the keypair used during server creation. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `launched_at` (String) Date-time when the server was launched +- `machine_type` (String) Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html) +- `name` (String) The name of the server. +- `updated_at` (String) Date-time when the server was updated +- `user_data` (String) User data that is passed via cloud-init to the server. + + +### Nested Schema for `boot_volume` + +Read-Only: + +- `id` (String) The ID of the source, either image ID or volume ID +- `performance_class` (String) The performance class of the server. +- `size` (Number) The size of the boot volume in GB. +- `type` (String) The type of the source. Supported values are: `volume`, `image`. diff --git a/docs/data-sources/volume.md b/docs/data-sources/volume.md new file mode 100644 index 00000000..87cb1a68 --- /dev/null +++ b/docs/data-sources/volume.md @@ -0,0 +1,51 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_volume Data Source - stackit" +subcategory: "" +description: |- + Volume 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. +--- + +# stackit_volume (Data Source) + +Volume 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. + +## Example Usage + +```terraform +data "stackit_volume" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the volume is associated. +- `volume_id` (String) The volume ID. + +### Read-Only + +- `availability_zone` (String) The availability zone of the volume. +- `description` (String) The description of the volume. +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`volume_id`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `name` (String) The name of the volume. +- `performance_class` (String) The performance class of the volume. +- `server_id` (String) The server ID of the server to which the volume is attached to. +- `size` (Number) The size of the volume in GB. It can only be updated to a larger value than the current size +- `source` (Attributes) The source of the volume. It can be either a volume, an image, a snapshot or a backup (see [below for nested schema](#nestedatt--source)) + + +### Nested Schema for `source` + +Read-Only: + +- `id` (String) The ID of the source, e.g. image ID +- `type` (String) The type of the source. Supported values are: `volume`, `image`, `snapshot`, `backup`. diff --git a/docs/resources/loadbalancer.md b/docs/resources/loadbalancer.md index 48034f97..a4553d68 100644 --- a/docs/resources/loadbalancer.md +++ b/docs/resources/loadbalancer.md @@ -8,23 +8,25 @@ description: |- To automate the creation of load balancers, OpenStack can be used to setup the supporting infrastructure. To set up the OpenStack provider, you can create a token through the STACKIT Portal, in your project's Infrastructure API page. There, the OpenStack user domain name, username, and password are generated and can be obtained. The provider can then be configured as follows: - ```terraform + terraform { - required_providers { - (...) - openstack = { - source = "terraform-provider-openstack/openstack" - } - } + required_providers { + (...) + openstack = { + source = "terraform-provider-openstack/openstack" + } + } } + provider "openstack" { - user_domain_name = "{OpenStack user domain name}" - user_name = "{OpenStack username}" - password = "{OpenStack password}" - region = "RegionOne" - auth_url = "https://keystone.api.iaas.eu01.stackit.cloud/v3" + user_domain_name = "{OpenStack user domain name}" + user_name = "{OpenStack username}" + password = "{OpenStack password}" + region = "RegionOne" + auth_url = "https://keystone.api.iaas.eu01.stackit.cloud/v3" } - ``` + + Configuring the supporting infrastructure The example below uses OpenStack to create the network, router, a public IP address and a compute instance. --- diff --git a/docs/resources/network.md b/docs/resources/network.md index 3032f50c..7806f22e 100644 --- a/docs/resources/network.md +++ b/docs/resources/network.md @@ -18,6 +18,9 @@ resource "stackit_network" "example" { name = "example-network" nameservers = ["1.2.3.4", "5.6.7.8"] ipv4_prefix_length = 24 + labels = { + "key" = "value" + } } ``` @@ -32,6 +35,7 @@ resource "stackit_network" "example" { ### Optional - `ipv4_prefix_length` (Number) The IPv4 prefix length of the network. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container - `nameservers` (List of String) The nameservers of the network. ### Read-Only diff --git a/docs/resources/network_area.md b/docs/resources/network_area.md index 768b9245..2c2985a5 100644 --- a/docs/resources/network_area.md +++ b/docs/resources/network_area.md @@ -4,12 +4,15 @@ page_title: "stackit_network_area Resource - stackit" subcategory: "" description: |- 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. --- # stackit_network_area (Resource) 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. + ## Example Usage ```terraform @@ -18,10 +21,13 @@ resource "stackit_network_area" "example" { name = "example-network-area" network_ranges = [ { - prefix = "1.2.3.4" + prefix = "192.168.0.0/24" } ] - transfer_network = "1.2.3.4/5" + transfer_network = "192.168.0.0/24" + labels = { + "key" = "value" + } } ``` @@ -39,14 +45,13 @@ resource "stackit_network_area" "example" { - `default_nameservers` (List of String) List of DNS Servers/Nameservers. - `default_prefix_length` (Number) The default prefix length for networks in the network area. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container - `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. +- `id` (String) Terraform's internal resource ID. It is structured as "`organization_id`,`network_area_id`". - `network_area_id` (String) The network area ID. - `project_count` (Number) The amount of projects currently referencing this area. diff --git a/docs/resources/network_area_route.md b/docs/resources/network_area_route.md index ba4bf998..ef3dd862 100644 --- a/docs/resources/network_area_route.md +++ b/docs/resources/network_area_route.md @@ -4,20 +4,26 @@ page_title: "stackit_network_area_route Resource - stackit" subcategory: "" description: |- 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. --- # stackit_network_area_route (Resource) 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. + ## Example Usage ```terraform -resource "stackit_network_area" "example" { +resource "stackit_network_area_route" "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" + prefix = "192.168.0.0/24" + next_hop = "192.168.0.0" + labels = { + "key" = "value" + } } ``` @@ -31,9 +37,11 @@ resource "stackit_network_area" "example" { - `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. +### Optional + +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container + ### 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. +- `id` (String) Terraform's internal resource ID. It is structured as "`organization_id`,`network_area_id`,`network_area_route_id`". - `network_area_route_id` (String) The network area route ID. diff --git a/docs/resources/network_interface.md b/docs/resources/network_interface.md new file mode 100644 index 00000000..29b7e4cd --- /dev/null +++ b/docs/resources/network_interface.md @@ -0,0 +1,50 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_network_interface Resource - stackit" +subcategory: "" +description: |- + Network interface 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. +--- + +# stackit_network_interface (Resource) + +Network interface 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. + +## Example Usage + +```terraform +resource "stackit_network_interface" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + allowed_addresses = ["192.168.0.0/24"] + security_group_ids = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] +} +``` + + +## Schema + +### Required + +- `network_id` (String) The network ID to which the network interface is associated. +- `project_id` (String) STACKIT project ID to which the network is associated. + +### Optional + +- `allowed_addresses` (List of String) The list of CIDR (Classless Inter-Domain Routing) notations. +- `ipv4` (String) The IPv4 address. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a network interface. +- `name` (String) The name of the network interface. +- `security` (Boolean) The Network Interface Security. If set to false, then no security groups will apply to this network interface. +- `security_group_ids` (List of String) The list of security group UUIDs. If security is set to false, setting this field will lead to an error. + +### Read-Only + +- `device` (String) The device UUID of the network interface. +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`network_id`,`network_interface_id`". +- `mac` (String) The MAC address of network interface. +- `network_interface_id` (String) The network interface ID. +- `type` (String) Type of network interface. Some of the possible values are: Supported values are: `server`, `metadata`, `gateway`. diff --git a/docs/resources/public_ip.md b/docs/resources/public_ip.md new file mode 100644 index 00000000..4ea9e8d9 --- /dev/null +++ b/docs/resources/public_ip.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_public_ip Resource - stackit" +subcategory: "" +description: |- + Public IP 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. +--- + +# stackit_public_ip (Resource) + +Public IP 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. + +## Example Usage + +```terraform +resource "stackit_public_ip" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + labels = { + "key" = "value" + } +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the public IP is associated. + +### Optional + +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `network_interface_id` (String) Associates the public IP with a network interface or a virtual IP (ID). + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`public_ip_id`". +- `ip` (String) The IP address. +- `public_ip_id` (String) The public IP ID. diff --git a/docs/resources/security_group.md b/docs/resources/security_group.md new file mode 100644 index 00000000..bc5ee7ca --- /dev/null +++ b/docs/resources/security_group.md @@ -0,0 +1,45 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_security_group Resource - stackit" +subcategory: "" +description: |- + Security group 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. +--- + +# stackit_security_group (Resource) + +Security group 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. + +## Example Usage + +```terraform +resource "stackit_security_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_security_group" + labels = { + "key" = "value" + } +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the security group. +- `project_id` (String) STACKIT project ID to which the security group is associated. + +### Optional + +- `description` (String) The description of the security group. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `stateful` (Boolean) Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`security_group_id`". +- `security_group_id` (String) The security group ID. diff --git a/docs/resources/security_group_rule.md b/docs/resources/security_group_rule.md new file mode 100644 index 00000000..ac8a3f6f --- /dev/null +++ b/docs/resources/security_group_rule.md @@ -0,0 +1,81 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_security_group_rule Resource - stackit" +subcategory: "" +description: |- + Security group rule 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. +--- + +# stackit_security_group_rule (Resource) + +Security group rule 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. + +## Example Usage + +```terraform +resource "stackit_security_group_rule" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + direction = "ingress" + icmp_parameters = { + code = 0 + type = 8 + } + protocol = { + name = "icmp" + } +} +``` + + +## Schema + +### Required + +- `direction` (String) The direction of the traffic which the rule should match. Some of the possible values are: Supported values are: `ingress`, `egress`. +- `project_id` (String) STACKIT project ID to which the security group rule is associated. +- `security_group_id` (String) The security group ID. + +### Optional + +- `description` (String) The rule description. +- `ether_type` (String) The ethertype which the rule should match. +- `icmp_parameters` (Attributes) ICMP Parameters. These parameters should only be provided if the protocol is ICMP. (see [below for nested schema](#nestedatt--icmp_parameters)) +- `ip_range` (String) The remote IP range which the rule should match. +- `port_range` (Attributes) The range of ports. This should only be provided if the protocol is not ICMP. (see [below for nested schema](#nestedatt--port_range)) +- `protocol` (Attributes) The internet protocol which the rule should match. (see [below for nested schema](#nestedatt--protocol)) +- `remote_security_group_id` (String) The remote security group which the rule should match. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`security_group_id`,`security_group_rule_id`". +- `security_group_rule_id` (String) The security group rule ID. + + +### Nested Schema for `icmp_parameters` + +Required: + +- `code` (Number) ICMP code. Can be set if the protocol is ICMP. +- `type` (Number) ICMP type. Can be set if the protocol is ICMP. + + + +### Nested Schema for `port_range` + +Required: + +- `max` (Number) The maximum port number. Should be greater or equal to the minimum. +- `min` (Number) The minimum port number. Should be less or equal to the maximum. + + + +### Nested Schema for `protocol` + +Optional: + +- `name` (String) The protocol name which the rule should match. Either `name` or `number` must be provided. +- `number` (Number) The protocol number which the rule should match. Either `name` or `number` must be provided. diff --git a/docs/resources/server.md b/docs/resources/server.md new file mode 100644 index 00000000..58a43632 --- /dev/null +++ b/docs/resources/server.md @@ -0,0 +1,374 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_server Resource - stackit" +subcategory: "" +description: |- + Server 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. + Example Usage + Boot from volume + + resource "stackit_server" "boot-from-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + availability_zone = "eu01-1" + machine_type = "g1.1" + keypair_name = "example-keypair" + } + + + Boot from existing volume + + resource "stackit_volume" "example-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + size = 12 + source = { + type = "image" + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-volume" + availability_zone = "eu01-1" + } + + resource "stackit_server" "boot-from-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + source_type = "volume" + source_id = stackit_volume.example-volume.volume_id + } + availability_zone = "eu01-1" + machine_type = "g1.1" + keypair_name = "example-keypair" + } + + + Network setup + + resource "stackit_server" "server-with-network" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + machine_type = "g1.1" + keypair_name = "example-keypair" + } + + resource "stackit_network" "network" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-network" + nameservers = ["192.0.2.0", "198.51.100.0", "203.0.113.0"] + ipv4_prefix_length = 24 + } + + resource "stackit_security_group" "sec-group" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-security-group" + stateful = true + } + + resource "stackit_security_group_rule" "rule" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = stackit_security_group.sec-group.security_group_id + direction = "ingress" + ether_type = "IPv4" + } + + resource "stackit_network_interface" "nic" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = stackit_network.network.network_id + security_group_ids = [stackit_security_group.sec-group.security_group_id] + } + + resource "stackit_public_ip" "public-ip" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = stackit_network_interface.nic.network_interface_id + } + + resource "stackit_server_network_interface_attach" "nic-attachment" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = stackit_server.server-with-network.server_id + network_interface_id = stackit_network_interface.nic.network_interface_id + } + + + Server with attached volume + + resource "stackit_volume" "example-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + size = 12 + source = { + type = "image" + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-volume" + availability_zone = "eu01-1" + } + + resource "stackit_server" "server-with-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + availability_zone = "eu01-1" + machine_type = "g1.1" + keypair_name = "example-keypair" + } + + resource "stackit_server_volume_attach" "attach_volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = stackit_server.server-with-volume.server_id + volume_id = stackit_volume.example-volume.volume_id + } + + + Server with user data (cloud-init) + + resource "stackit_server" "user-data" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-server" + machine_type = "g1.1" + keypair_name = "example-keypair" + user_data = "#!/bin/bash\n/bin/su" + } + + resource "stackit_server" "user-data-from-file" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-server" + machine_type = "g1.1" + keypair_name = "example-keypair" + user_data = file("${path.module}/cloud-init.yaml") + } +--- + +# stackit_server (Resource) + +Server 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. +## Example Usage + + +### Boot from volume +```terraform +resource "stackit_server" "boot-from-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + availability_zone = "eu01-1" + machine_type = "g1.1" + keypair_name = "example-keypair" +} + +``` + +### Boot from existing volume +```terraform +resource "stackit_volume" "example-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + size = 12 + source = { + type = "image" + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-volume" + availability_zone = "eu01-1" +} + +resource "stackit_server" "boot-from-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + source_type = "volume" + source_id = stackit_volume.example-volume.volume_id + } + availability_zone = "eu01-1" + machine_type = "g1.1" + keypair_name = "example-keypair" +} + +``` + +### Network setup +```terraform +resource "stackit_server" "server-with-network" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + machine_type = "g1.1" + keypair_name = "example-keypair" +} + +resource "stackit_network" "network" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-network" + nameservers = ["192.0.2.0", "198.51.100.0", "203.0.113.0"] + ipv4_prefix_length = 24 +} + +resource "stackit_security_group" "sec-group" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-security-group" + stateful = true +} + +resource "stackit_security_group_rule" "rule" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = stackit_security_group.sec-group.security_group_id + direction = "ingress" + ether_type = "IPv4" +} + +resource "stackit_network_interface" "nic" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = stackit_network.network.network_id + security_group_ids = [stackit_security_group.sec-group.security_group_id] +} + +resource "stackit_public_ip" "public-ip" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = stackit_network_interface.nic.network_interface_id +} + +resource "stackit_server_network_interface_attach" "nic-attachment" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = stackit_server.server-with-network.server_id + network_interface_id = stackit_network_interface.nic.network_interface_id +} + +``` + +### Server with attached volume +```terraform +resource "stackit_volume" "example-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + size = 12 + source = { + type = "image" + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-volume" + availability_zone = "eu01-1" +} + +resource "stackit_server" "server-with-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + availability_zone = "eu01-1" + machine_type = "g1.1" + keypair_name = "example-keypair" +} + +resource "stackit_server_volume_attach" "attach_volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = stackit_server.server-with-volume.server_id + volume_id = stackit_volume.example-volume.volume_id +} + +``` + +### Server with user data (cloud-init) +```terraform +resource "stackit_server" "user-data" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-server" + machine_type = "g1.1" + keypair_name = "example-keypair" + user_data = "#!/bin/bash\n/bin/su" +} + +resource "stackit_server" "user-data-from-file" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-server" + machine_type = "g1.1" + keypair_name = "example-keypair" + user_data = file("${path.module}/cloud-init.yaml") +} + +``` + + + + +## Schema + +### Required + +- `machine_type` (String) Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html) +- `name` (String) The name of the server. +- `project_id` (String) STACKIT project ID to which the server is associated. + +### Optional + +- `affinity_group` (String) The affinity group the server is assigned to. +- `availability_zone` (String) The availability zone of the server. +- `boot_volume` (Attributes) The boot volume for the server (see [below for nested schema](#nestedatt--boot_volume)) +- `image_id` (String) The image ID to be used for an ephemeral disk on the server. +- `keypair_name` (String) The name of the keypair used during server creation. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `user_data` (String) User data that is passed via cloud-init to the server. + +### Read-Only + +- `created_at` (String) Date-time when the server was created +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`". +- `launched_at` (String) Date-time when the server was launched +- `server_id` (String) The server ID. +- `updated_at` (String) Date-time when the server was updated + + +### Nested Schema for `boot_volume` + +Required: + +- `source_id` (String) The ID of the source, either image ID or volume ID +- `source_type` (String) The type of the source. Supported values are: `volume`, `image`. + +Optional: + +- `performance_class` (String) The performance class of the server. +- `size` (Number) The size of the boot volume in GB. Must be provided when `source_type` is `image`. diff --git a/docs/resources/server_network_interface_attach.md b/docs/resources/server_network_interface_attach.md new file mode 100644 index 00000000..6b2d5262 --- /dev/null +++ b/docs/resources/server_network_interface_attach.md @@ -0,0 +1,37 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_server_network_interface_attach Resource - stackit" +subcategory: "" +description: |- + Network interface attachment resource schema. Attaches a network interface to a server. 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_server_network_interface_attach (Resource) + +Network interface attachment resource schema. Attaches a network interface to a server. 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 +resource "stackit_server_network_interface_attach" "attached_network_interface" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `network_interface_id` (String) The network interface ID. +- `project_id` (String) STACKIT project ID to which the network interface attachment is associated. +- `server_id` (String) The server ID. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`,`network_interface_id`". diff --git a/docs/resources/server_service_account_attach.md b/docs/resources/server_service_account_attach.md new file mode 100644 index 00000000..d826cac1 --- /dev/null +++ b/docs/resources/server_service_account_attach.md @@ -0,0 +1,37 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_server_service_account_attach Resource - stackit" +subcategory: "" +description: |- + Service account attachment resource schema. Attaches a service account to a server. 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_server_service_account_attach (Resource) + +Service account attachment resource schema. Attaches a service account to a server. 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 +resource "stackit_server_service_account_attach" "attached_service_account" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + service_account_email = "service-account@stackit.cloud" +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the service account attachment is associated. +- `server_id` (String) The server ID. +- `service_account_email` (String) The service account email. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`,`service_account_email`". diff --git a/docs/resources/server_volume_attach.md b/docs/resources/server_volume_attach.md new file mode 100644 index 00000000..8841bae4 --- /dev/null +++ b/docs/resources/server_volume_attach.md @@ -0,0 +1,37 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_server_volume_attach Resource - stackit" +subcategory: "" +description: |- + Volume attachment resource schema. Attaches a volume to a server. 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_server_volume_attach (Resource) + +Volume attachment resource schema. Attaches a volume to a server. 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 +resource "stackit_server_volume_attach" "attached_volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the volume attachment is associated. +- `server_id` (String) The server ID. +- `volume_id` (String) The volume ID. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`,`volume_id`". diff --git a/docs/resources/sqlserverflex_user.md b/docs/resources/sqlserverflex_user.md index 79b28166..82c52176 100644 --- a/docs/resources/sqlserverflex_user.md +++ b/docs/resources/sqlserverflex_user.md @@ -3,12 +3,12 @@ page_title: "stackit_sqlserverflex_user Resource - stackit" subcategory: "" description: |- - [Warning: BETA] SQLServer Flex user resource schema. Must have a region specified in the provider configuration. + SQLServer Flex user resource schema. Must have a region specified in the provider configuration. --- # stackit_sqlserverflex_user (Resource) -[Warning: BETA] SQLServer Flex user resource schema. Must have a `region` specified in the provider configuration. +SQLServer Flex user resource schema. Must have a `region` specified in the provider configuration. ## Example Usage diff --git a/docs/resources/volume.md b/docs/resources/volume.md new file mode 100644 index 00000000..3230ffb8 --- /dev/null +++ b/docs/resources/volume.md @@ -0,0 +1,59 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_volume Resource - stackit" +subcategory: "" +description: |- + Volume 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. +--- + +# stackit_volume (Resource) + +Volume 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. + +## Example Usage + +```terraform +resource "stackit_volume" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_volume" + availability_zone = "eu01-1" + size = 64 + labels = { + "key" = "value" + } +} +``` + + +## Schema + +### Required + +- `availability_zone` (String) The availability zone of the volume. +- `project_id` (String) STACKIT project ID to which the volume is associated. + +### Optional + +- `description` (String) The description of the volume. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `name` (String) The name of the volume. +- `performance_class` (String) The performance class of the volume. +- `server_id` (String) The server ID of the server to which the volume is attached to. +- `size` (Number) The size of the volume in GB. It can only be updated to a larger value than the current size. Either `size` or `source` must be provided +- `source` (Attributes) The source of the volume. It can be either a volume, an image, a snapshot or a backup. Either `size` or `source` must be provided (see [below for nested schema](#nestedatt--source)) + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`volume_id`". +- `volume_id` (String) The volume ID. + + +### Nested Schema for `source` + +Required: + +- `id` (String) The ID of the source, e.g. image ID +- `type` (String) The type of the source. Supported values are: `volume`, `image`, `snapshot`, `backup`. diff --git a/examples/data-sources/stackit_network_interface/data-source.tf b/examples/data-sources/stackit_network_interface/data-source.tf new file mode 100644 index 00000000..2c223f40 --- /dev/null +++ b/examples/data-sources/stackit_network_interface/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_network_interface" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} \ No newline at end of file diff --git a/examples/data-sources/stackit_public_ip/data-source.tf b/examples/data-sources/stackit_public_ip/data-source.tf new file mode 100644 index 00000000..731d9ed7 --- /dev/null +++ b/examples/data-sources/stackit_public_ip/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_public_ip" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + public_ip_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/data-sources/stackit_security_group/data-source.tf b/examples/data-sources/stackit_security_group/data-source.tf new file mode 100644 index 00000000..ebb69e53 --- /dev/null +++ b/examples/data-sources/stackit_security_group/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_security_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/data-sources/stackit_security_group_rule/data-source.tf b/examples/data-sources/stackit_security_group_rule/data-source.tf new file mode 100644 index 00000000..ad27c79d --- /dev/null +++ b/examples/data-sources/stackit_security_group_rule/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_security_group_rule" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_rule_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/data-sources/stackit_server/datasource.tf b/examples/data-sources/stackit_server/datasource.tf new file mode 100644 index 00000000..16c231f5 --- /dev/null +++ b/examples/data-sources/stackit_server/datasource.tf @@ -0,0 +1,4 @@ +data "stackit_server" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} \ No newline at end of file diff --git a/examples/data-sources/stackit_volume/data-source.tf b/examples/data-sources/stackit_volume/data-source.tf new file mode 100644 index 00000000..ee380b0a --- /dev/null +++ b/examples/data-sources/stackit_volume/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_volume" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_network/resource.tf b/examples/resources/stackit_network/resource.tf index 10bb9831..213e180c 100644 --- a/examples/resources/stackit_network/resource.tf +++ b/examples/resources/stackit_network/resource.tf @@ -3,4 +3,7 @@ resource "stackit_network" "example" { name = "example-network" nameservers = ["1.2.3.4", "5.6.7.8"] ipv4_prefix_length = 24 + labels = { + "key" = "value" + } } diff --git a/examples/resources/stackit_network_area/resource.tf b/examples/resources/stackit_network_area/resource.tf index 9d2267ad..2653c9b0 100644 --- a/examples/resources/stackit_network_area/resource.tf +++ b/examples/resources/stackit_network_area/resource.tf @@ -3,8 +3,11 @@ resource "stackit_network_area" "example" { name = "example-network-area" network_ranges = [ { - prefix = "1.2.3.4" + prefix = "192.168.0.0/24" } ] - transfer_network = "1.2.3.4/5" + transfer_network = "192.168.0.0/24" + labels = { + "key" = "value" + } } diff --git a/examples/resources/stackit_network_area_route/resource.tf b/examples/resources/stackit_network_area_route/resource.tf index 48a608cc..b1caeba0 100644 --- a/examples/resources/stackit_network_area_route/resource.tf +++ b/examples/resources/stackit_network_area_route/resource.tf @@ -1,6 +1,9 @@ -resource "stackit_network_area" "example" { +resource "stackit_network_area_route" "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" + prefix = "192.168.0.0/24" + next_hop = "192.168.0.0" + labels = { + "key" = "value" + } } diff --git a/examples/resources/stackit_network_interface/resource.tf b/examples/resources/stackit_network_interface/resource.tf new file mode 100644 index 00000000..181e63ed --- /dev/null +++ b/examples/resources/stackit_network_interface/resource.tf @@ -0,0 +1,6 @@ +resource "stackit_network_interface" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + allowed_addresses = ["192.168.0.0/24"] + security_group_ids = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] +} \ No newline at end of file diff --git a/examples/resources/stackit_public_ip/resource.tf b/examples/resources/stackit_public_ip/resource.tf new file mode 100644 index 00000000..b7f3f5de --- /dev/null +++ b/examples/resources/stackit_public_ip/resource.tf @@ -0,0 +1,7 @@ +resource "stackit_public_ip" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + labels = { + "key" = "value" + } +} diff --git a/examples/resources/stackit_security_group/resource.tf b/examples/resources/stackit_security_group/resource.tf new file mode 100644 index 00000000..a67ea42d --- /dev/null +++ b/examples/resources/stackit_security_group/resource.tf @@ -0,0 +1,7 @@ +resource "stackit_security_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_security_group" + labels = { + "key" = "value" + } +} diff --git a/examples/resources/stackit_security_group_rule/resource.tf b/examples/resources/stackit_security_group_rule/resource.tf new file mode 100644 index 00000000..0e308bd3 --- /dev/null +++ b/examples/resources/stackit_security_group_rule/resource.tf @@ -0,0 +1,12 @@ +resource "stackit_security_group_rule" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + direction = "ingress" + icmp_parameters = { + code = 0 + type = 8 + } + protocol = { + name = "icmp" + } +} diff --git a/examples/resources/stackit_server_network_interface_attach/resource.tf b/examples/resources/stackit_server_network_interface_attach/resource.tf new file mode 100644 index 00000000..aaf85d18 --- /dev/null +++ b/examples/resources/stackit_server_network_interface_attach/resource.tf @@ -0,0 +1,5 @@ +resource "stackit_server_network_interface_attach" "attached_network_interface" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_server_service_account_attach/resource.tf b/examples/resources/stackit_server_service_account_attach/resource.tf new file mode 100644 index 00000000..dbe7fa0b --- /dev/null +++ b/examples/resources/stackit_server_service_account_attach/resource.tf @@ -0,0 +1,5 @@ +resource "stackit_server_service_account_attach" "attached_service_account" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + service_account_email = "service-account@stackit.cloud" +} diff --git a/examples/resources/stackit_server_volume_attach/resource.tf b/examples/resources/stackit_server_volume_attach/resource.tf new file mode 100644 index 00000000..dadb093d --- /dev/null +++ b/examples/resources/stackit_server_volume_attach/resource.tf @@ -0,0 +1,5 @@ +resource "stackit_server_volume_attach" "attached_volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_volume/resource.tf b/examples/resources/stackit_volume/resource.tf new file mode 100644 index 00000000..ef88623b --- /dev/null +++ b/examples/resources/stackit_volume/resource.tf @@ -0,0 +1,9 @@ +resource "stackit_volume" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_volume" + availability_zone = "eu01-1" + size = 64 + labels = { + "key" = "value" + } +} diff --git a/go.mod b/go.mod index c03b192d..be8231ed 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.14.0 github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.11.0 - github.com/stackitcloud/stackit-sdk-go/services/iaas v0.12.0 + github.com/stackitcloud/stackit-sdk-go/services/iaas v0.14.0 github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.20.0 diff --git a/go.sum b/go.sum index 3b1f8736..a487c44b 100644 --- a/go.sum +++ b/go.sum @@ -155,8 +155,8 @@ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.4.0 h1:WXSIE4Kf github.com/stackitcloud/stackit-sdk-go/services/authorization v0.4.0/go.mod h1:8spVqlPqZrvQQ63Qodbydk3qsZx7lr963ECft+sqFhY= github.com/stackitcloud/stackit-sdk-go/services/dns v0.11.0 h1:+OZ82DwFy4JIJThadVjvll5kUWjHPSLbUIF65njsNBk= github.com/stackitcloud/stackit-sdk-go/services/dns v0.11.0/go.mod h1:mv8U7kuclXo+0VpDHtBCkve/3i9h1yT+RAId/MUi+C8= -github.com/stackitcloud/stackit-sdk-go/services/iaas v0.12.0 h1:tk/ztsZoFgcsyEFODaej8IGGuvprVbsrrJKGzm2PR14= -github.com/stackitcloud/stackit-sdk-go/services/iaas v0.12.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM= +github.com/stackitcloud/stackit-sdk-go/services/iaas v0.14.0 h1:2CLlZInB7ytcnIpPUhFSbCBrNp0mMhxIuGOxlz3rhoA= +github.com/stackitcloud/stackit-sdk-go/services/iaas v0.14.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 h1:06CGP64CEk3Zg6i9kZCMRdmCzLLiyMWQqGK1teBr9Oc= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0/go.mod h1:JL94zc8K0ebWs+DBGXR28vNCF0EFV54ZLUtrlXOvWgA= github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.0 h1:V0UGP7JEa4Q8SsZFUJsKgLGaoPruLn2KVKnqQtaoWCU= diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go index ccc017c3..ee009b13 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -133,3 +133,37 @@ func StringListToPointer(list basetypes.ListValue) (*[]string, error) { return &listStr, nil } + +// ToJSONMApPartialUpdatePayload returns a map[string]interface{} to be used in a PATCH request payload. +// It takes a current map as it is in the terraform state and a desired map as it is in the user configuratiom +// and builds a map which sets to null keys that should be removed, updates the values of existing keys and adds new keys +// This method is needed because in partial updates, e.g. if the key is not provided it is ignored and not removed +func ToJSONMapPartialUpdatePayload(ctx context.Context, current, desired types.Map) (map[string]interface{}, error) { + currentMap, err := ToStringInterfaceMap(ctx, current) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + desiredMap, err := ToStringInterfaceMap(ctx, desired) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + mapPayload := map[string]interface{}{} + // Update and remove existing keys + for k := range currentMap { + if desiredValue, ok := desiredMap[k]; ok { + mapPayload[k] = desiredValue + } else { + mapPayload[k] = nil + } + } + + // Add new keys + for k, desiredValue := range desiredMap { + if _, ok := mapPayload[k]; !ok { + mapPayload[k] = desiredValue + } + } + return mapPayload, nil +} diff --git a/stackit/internal/conversion/conversion_test.go b/stackit/internal/conversion/conversion_test.go index 5662e7d3..b37b154e 100644 --- a/stackit/internal/conversion/conversion_test.go +++ b/stackit/internal/conversion/conversion_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -80,3 +81,139 @@ func TestFromTerraformStringMapToInterfaceMap(t *testing.T) { }) } } + +func TestToJSONMapUpdatePayload(t *testing.T) { + tests := []struct { + description string + currentLabels types.Map + desiredLabels types.Map + expected map[string]interface{} + isValid bool + }{ + { + "nothing_to_update", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + map[string]interface{}{ + "key": "value", + }, + true, + }, + { + "update_key_value", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("updated_value"), + }), + map[string]interface{}{ + "key": "updated_value", + }, + true, + }, + { + "remove_key", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + map[string]interface{}{ + "key": "value", + "key2": nil, + }, + true, + }, + { + "add_new_key", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + true, + }, + { + "empty_desired_map", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{}), + map[string]interface{}{ + "key": nil, + "key2": nil, + }, + true, + }, + { + "nil_desired_map", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + types.MapNull(types.StringType), + map[string]interface{}{ + "key": nil, + "key2": nil, + }, + true, + }, + { + "empty_current_map", + types.MapValueMust(types.StringType, map[string]attr.Value{}), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + true, + }, + { + "nil_current_map", + types.MapNull(types.StringType), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := ToJSONMapPartialUpdatePayload(context.Background(), tt.currentLabels, tt.desiredLabels) + 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/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index 407df54f..00e652ff 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -16,6 +16,11 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) +const ( + serverMachineType = "t1.1" + updatedServerMachineType = "t1.2" +) + // Network resource data var networkResource = map[string]string{ "project_id": testutil.ProjectId, @@ -39,6 +44,58 @@ var networkAreaRouteResource = map[string]string{ "next_hop": "1.1.1.1", } +var networkInterfaceResource = map[string]string{ + "project_id": testutil.ProjectId, + "network_id": networkResource["network_id"], + "name": "name", +} + +// Volume resource data +var volumeResource = map[string]string{ + "project_id": testutil.ProjectId, + "availability_zone": "eu01-1", + "name": fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlpha)), + "description": "description", + "size": "1", + "label1": "value", + "performance_class": "storage_premium_perf1", +} + +// Server resource data +var serverResource = map[string]string{ + "project_id": testutil.ProjectId, + "availability_zone": "eu01-1", + "size": "64", + "source_type": "image", + "source_id": testutil.IaaSImageId, + "name": fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlpha)), + "machine_type": serverMachineType, + "label1": "value", + "user_data": "#!/bin/bash", +} + +// Security Group resource data +var securityGroupResource = map[string]string{ + "project_id": testutil.ProjectId, + "name": "name", + "description": "description", + "label1": "value", +} + +// Security Group rule resource data +var securityGroupRuleResource = map[string]string{ + "project_id": testutil.ProjectId, + "direction": "ingress", + "description": "description", +} + +// Public IP resource data +var publicIpResource = map[string]string{ + "project_id": testutil.ProjectId, + "label1": "value", + "network_interface_id": testutil.IaaSNetworkInterfaceId, +} + func networkResourceConfig(name, nameservers string) string { return fmt.Sprintf(` resource "stackit_network" "network" { @@ -87,40 +144,157 @@ func networkAreaRouteResourceConfig() string { ) } -func resourceConfig(name, nameservers, areaname, networkranges string) string { - return fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s", +func networkInterfaceResourceConfig(name string) string { + return fmt.Sprintf(` + resource "stackit_network_interface" "network_interface" { + project_id = stackit_network.network.project_id + network_id = stackit_network.network.network_id + name = "%s" + } + `, + name, + ) +} + +func volumeResourceConfig(name, size string) string { + return fmt.Sprintf(` + resource "stackit_volume" "volume" { + project_id = "%s" + availability_zone = "%s" + name = "%s" + description = "%s" + size = %s + labels = { + "label1" = "%s" + } + performance_class = "%s" + } + `, + volumeResource["project_id"], + volumeResource["availability_zone"], + name, + volumeResource["description"], + size, + volumeResource["label1"], + volumeResource["performance_class"], + ) +} + +func serverResourceConfig(name, machineType string) string { + return fmt.Sprintf(` + resource "stackit_server" "server" { + project_id = "%s" + availability_zone = "%s" + name = "%s" + machine_type = "%s" + boot_volume = { + size = %s + source_type = "%s" + source_id = "%s" + } + labels = { + "label1" = "%s" + } + user_data = "%s" + } + `, + serverResource["project_id"], + serverResource["availability_zone"], + name, + machineType, + serverResource["size"], + serverResource["source_type"], + serverResource["source_id"], + serverResource["label1"], + serverResource["user_data"], + ) +} + +func securityGroupResourceConfig(name string) string { + return fmt.Sprintf(` + resource "stackit_security_group" "security_group" { + project_id = "%s" + name = "%s" + description = "%s" + labels = { + "label1" = "%s" + } + } + `, + volumeResource["project_id"], + name, + volumeResource["description"], + volumeResource["label1"], + ) +} + +func securityGroupRuleResourceConfig(direction string) string { + return fmt.Sprintf(` + resource "stackit_security_group_rule" "security_group_rule" { + project_id = "%s" + security_group_id = stackit_security_group.security_group.security_group_id + direction = "%s" + description = "%s" + } + `, + securityGroupRuleResource["project_id"], + direction, + securityGroupRuleResource["description"], + ) +} + +func testAccNetworkAreaConfig(areaname, networkranges string) string { + return fmt.Sprintf("%s\n\n%s\n\n%s", testutil.IaaSProviderConfig(), - networkResourceConfig(name, nameservers), networkAreaResourceConfig(areaname, networkranges), networkAreaRouteResourceConfig(), ) } -func TestAccIaaS(t *testing.T) { +func testAccVolumeConfig(name, size string) string { + return fmt.Sprintf("%s\n\n%s", + testutil.IaaSProviderConfig(), + volumeResourceConfig(name, size), + ) +} + +func testAccServerConfig(name, nameservers, serverName, machineType, interfacename string) string { + return fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s", + testutil.IaaSProviderConfig(), + networkResourceConfig(name, nameservers), + serverResourceConfig(serverName, machineType), + networkInterfaceResourceConfig(interfacename), + ) +} + +func resourceConfigSecurityGroup(name, direction string) string { + return fmt.Sprintf("%s\n\n%s\n\n%s", + testutil.IaaSProviderConfig(), + securityGroupResourceConfig(name), + securityGroupRuleResourceConfig(direction), + ) +} + +func testAccPublicIpConfig(publicIpResourceConfig string) string { + return fmt.Sprintf("%s\n\n%s", + testutil.IaaSProviderConfig(), + publicIpResourceConfig, + ) +} + +func TestAccNetworkArea(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - CheckDestroy: testAccCheckIaaSDestroy, + CheckDestroy: testAccCheckNetworkAreaDestroy, Steps: []resource.TestStep{ // Creation { - Config: resourceConfig( - networkResource["name"], - fmt.Sprintf( - "[%q]", - networkResource["nameserver0"], - ), + Config: testAccNetworkAreaConfig( networkAreaResource["name"], networkAreaResource["networkrange0"], ), Check: resource.ComposeAggregateTestCheckFunc( - // Instance - resource.TestCheckResourceAttr("stackit_network.network", "project_id", networkResource["project_id"]), - resource.TestCheckResourceAttrSet("stackit_network.network", "network_id"), - 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"), @@ -147,42 +321,24 @@ func TestAccIaaS(t *testing.T) { { 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"], - ), + testAccNetworkAreaConfig( networkAreaResource["name"], networkAreaResource["networkrange0"], ), ), Check: resource.ComposeAggregateTestCheckFunc( - // Instance - resource.TestCheckResourceAttr("data.stackit_network.network", "project_id", networkResource["project_id"]), - resource.TestCheckResourceAttrPair( - "stackit_network.network", "network_id", - "data.stackit_network.network", "network_id", - ), - 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"]), @@ -209,23 +365,6 @@ func TestAccIaaS(t *testing.T) { ), }, // Import - { - ResourceName: "stackit_network.network", - ImportStateIdFunc: func(s *terraform.State) (string, error) { - r, ok := s.RootModule().Resources["stackit_network.network"] - if !ok { - return "", fmt.Errorf("couldn't find resource stackit_network.network") - } - networkId, ok := r.Primary.Attributes["network_id"] - if !ok { - return "", fmt.Errorf("couldn't find attribute network_id") - } - return fmt.Sprintf("%s,%s", testutil.ProjectId, networkId), nil - }, - ImportState: true, - 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) { @@ -264,25 +403,11 @@ func TestAccIaaS(t *testing.T) { }, // Update { - Config: resourceConfig( - fmt.Sprintf("%s-updated", networkResource["name"]), - fmt.Sprintf( - "[%q, %q]", - networkResource["nameserver0"], - networkResource["nameserver1"], - ), + Config: testAccNetworkAreaConfig( fmt.Sprintf("%s-updated", networkAreaResource["name"]), networkAreaResource["networkrange0"], ), Check: resource.ComposeAggregateTestCheckFunc( - // Instance - resource.TestCheckResourceAttr("stackit_network.network", "project_id", networkResource["project_id"]), - resource.TestCheckResourceAttrSet("stackit_network.network", "network_id"), - resource.TestCheckResourceAttr("stackit_network.network", "name", fmt.Sprintf("%s-updated", networkResource["name"])), - 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"), @@ -296,7 +421,560 @@ func TestAccIaaS(t *testing.T) { }) } -func testAccCheckIaaSDestroy(s *terraform.State) error { +func TestAccVolume(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIaaSVolumeDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: testAccVolumeConfig(volumeResource["name"], volumeResource["size"]), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_volume.volume", "project_id", volumeResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_volume.volume", "volume_id"), + resource.TestCheckResourceAttr("stackit_volume.volume", "name", volumeResource["name"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "labels.label1", volumeResource["label1"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "description", volumeResource["description"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "performance_class", volumeResource["performance_class"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "size", volumeResource["size"]), + ), + }, + // Data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_volume" "volume" { + project_id = stackit_volume.volume.project_id + volume_id = stackit_volume.volume.volume_id + } + `, + testAccVolumeConfig(volumeResource["name"], volumeResource["size"]), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_volume.volume", "project_id", networkResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_volume.volume", "volume_id", + "data.stackit_volume.volume", "volume_id", + ), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "name", volumeResource["name"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "labels.label1", volumeResource["label1"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "description", volumeResource["description"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "performance_class", volumeResource["performance_class"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "size", volumeResource["size"]), + ), + }, + // Import + { + ResourceName: "stackit_volume.volume", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_volume.volume"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_volume.volume") + } + volumeId, ok := r.Primary.Attributes["volume_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute volume_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, volumeId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: testAccVolumeConfig( + fmt.Sprintf("%s-updated", volumeResource["name"]), + "10", + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_volume.volume", "project_id", volumeResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_volume.volume", "volume_id"), + resource.TestCheckResourceAttr("stackit_volume.volume", "name", fmt.Sprintf("%s-updated", volumeResource["name"])), + resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "labels.label1", volumeResource["label1"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "description", volumeResource["description"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "performance_class", volumeResource["performance_class"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "size", "10"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccServer(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckServerDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: testAccServerConfig( + networkResource["name"], + fmt.Sprintf( + "[%q]", + networkResource["nameserver0"], + ), + serverResource["name"], + serverResource["machine_type"], + networkInterfaceResource["name"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + + // Network + resource.TestCheckResourceAttr("stackit_network.network", "project_id", networkResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_network.network", "network_id"), + resource.TestCheckResourceAttr("stackit_network.network", "name", networkResource["name"]), + resource.TestCheckResourceAttr("stackit_network.network", "nameservers.#", "1"), + resource.TestCheckResourceAttr("stackit_network.network", "nameservers.0", networkResource["nameserver0"]), + + // Server + resource.TestCheckResourceAttr("stackit_server.server", "project_id", serverResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_server.server", "server_id"), + resource.TestCheckResourceAttr("stackit_server.server", "name", serverResource["name"]), + resource.TestCheckResourceAttr("stackit_server.server", "availability_zone", serverResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_server.server", "machine_type", serverResource["machine_type"]), + resource.TestCheckResourceAttr("stackit_server.server", "labels.label1", serverResource["label1"]), + resource.TestCheckResourceAttr("stackit_server.server", "user_data", serverResource["user_data"]), + + // Network Interface + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "project_id", + "stackit_network.network", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "network_id", + "stackit_network.network", "network_id", + ), + resource.TestCheckResourceAttrSet("stackit_network_interface.network_interface", "network_interface_id"), + resource.TestCheckResourceAttr("stackit_network_interface.network_interface", "name", networkInterfaceResource["name"]), + ), + }, + // 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_server" "server" { + project_id = stackit_server.server.project_id + server_id = stackit_server.server.server_id + } + + data "stackit_network_interface" "network_interface" { + project_id = stackit_network.network.project_id + network_id = stackit_network.network.network_id + network_interface_id = stackit_network_interface.network_interface.network_interface_id + } + `, + testAccServerConfig( + networkResource["name"], + fmt.Sprintf( + "[%q]", + networkResource["nameserver0"], + ), + serverResource["name"], + serverResource["machine_type"], + networkInterfaceResource["name"], + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_network.network", "project_id", networkResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_network.network", "network_id", + "data.stackit_network.network", "network_id", + ), + resource.TestCheckResourceAttr("data.stackit_network.network", "name", networkResource["name"]), + resource.TestCheckResourceAttr("data.stackit_network.network", "nameservers.0", networkResource["nameserver0"]), + + // Server + resource.TestCheckResourceAttr("data.stackit_server.server", "project_id", serverResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_server.server", "server_id", + "data.stackit_server.server", "server_id", + ), + resource.TestCheckResourceAttr("data.stackit_server.server", "name", serverResource["name"]), + resource.TestCheckResourceAttr("data.stackit_server.server", "availability_zone", serverResource["availability_zone"]), + resource.TestCheckResourceAttr("data.stackit_server.server", "machine_type", serverResource["machine_type"]), + resource.TestCheckResourceAttr("data.stackit_server.server", "labels.label1", serverResource["label1"]), + + // Network Interface + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "project_id", + "stackit_network.network", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "network_id", + "stackit_network.network", "network_id", + ), + resource.TestCheckResourceAttrSet("stackit_network_interface.network_interface", "network_interface_id"), + resource.TestCheckResourceAttr("stackit_network_interface.network_interface", "name", networkInterfaceResource["name"]), + ), + }, + // Import + { + ResourceName: "stackit_network.network", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_network.network"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_network.network") + } + networkId, ok := r.Primary.Attributes["network_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, networkId), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"ipv4_prefix_length"}, // Field is not returned by the API + }, + { + ResourceName: "stackit_server.server", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_server.server"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_server.server") + } + serverId, ok := r.Primary.Attributes["server_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute server_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, serverId), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"boot_volume", "user_data"}, // Field is not mapped as it is only relevant on creation + }, + { + ResourceName: "stackit_network_interface.network_interface", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_network_interface.network_interface"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_network_interface.network_interface") + } + networkId, ok := r.Primary.Attributes["network_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_id") + } + networkInterfaceId, ok := r.Primary.Attributes["network_interface_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_interface_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, networkId, networkInterfaceId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: testAccServerConfig( + fmt.Sprintf("%s-updated", networkResource["name"]), + fmt.Sprintf( + "[%q, %q]", + networkResource["nameserver0"], + networkResource["nameserver1"], + ), + fmt.Sprintf("%s-updated", serverResource["name"]), + updatedServerMachineType, + fmt.Sprintf("%s-updated", networkInterfaceResource["name"]), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Network + resource.TestCheckResourceAttr("stackit_network.network", "project_id", networkResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_network.network", "network_id"), + resource.TestCheckResourceAttr("stackit_network.network", "name", fmt.Sprintf("%s-updated", networkResource["name"])), + 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"]), + + // Server + resource.TestCheckResourceAttr("stackit_server.server", "project_id", serverResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_server.server", "server_id"), + resource.TestCheckResourceAttr("stackit_server.server", "name", fmt.Sprintf("%s-updated", serverResource["name"])), + resource.TestCheckResourceAttr("stackit_server.server", "availability_zone", serverResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_server.server", "machine_type", updatedServerMachineType), + resource.TestCheckResourceAttr("stackit_server.server", "labels.label1", serverResource["label1"]), + resource.TestCheckResourceAttr("stackit_server.server", "user_data", serverResource["user_data"]), + + // Network interface + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "project_id", + "stackit_network.network", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "network_id", + "stackit_network.network", "network_id", + ), + resource.TestCheckResourceAttrSet("stackit_network_interface.network_interface", "network_interface_id"), + resource.TestCheckResourceAttr("stackit_network_interface.network_interface", "name", fmt.Sprintf("%s-updated", networkInterfaceResource["name"])), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccIaaSSecurityGroup(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIaaSSecurityGroupDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: resourceConfigSecurityGroup( + securityGroupResource["name"], + securityGroupRuleResource["direction"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Security Group + resource.TestCheckResourceAttr("stackit_security_group.security_group", "project_id", securityGroupResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_security_group.security_group", "security_group_id"), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "name", securityGroupResource["name"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "labels.label1", securityGroupResource["label1"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "description", securityGroupResource["description"]), + + // Security Group Rule + resource.TestCheckResourceAttrPair( + "stackit_security_group_rule.security_group_rule", "project_id", + "stackit_security_group.security_group", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_security_group_rule.security_group_rule", "security_group_id", + "stackit_security_group.security_group", "security_group_id", + ), + resource.TestCheckResourceAttrSet("stackit_security_group_rule.security_group_rule", "security_group_rule_id"), + resource.TestCheckResourceAttr("stackit_security_group_rule.security_group_rule", "direction", securityGroupRuleResource["direction"]), + resource.TestCheckResourceAttr("stackit_security_group_rule.security_group_rule", "description", securityGroupRuleResource["description"]), + ), + }, + // Data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_security_group" "security_group" { + project_id = stackit_security_group.security_group.project_id + security_group_id = stackit_security_group.security_group.security_group_id + } + + data "stackit_security_group_rule" "security_group_rule" { + project_id = stackit_security_group.security_group.project_id + security_group_id = stackit_security_group.security_group.security_group_id + security_group_rule_id = stackit_security_group_rule.security_group_rule.security_group_rule_id + } + `, + resourceConfigSecurityGroup( + securityGroupResource["name"], + securityGroupRuleResource["direction"], + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_security_group.security_group", "project_id", securityGroupResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_security_group.security_group", "security_group_id", + "data.stackit_security_group.security_group", "security_group_id", + ), + resource.TestCheckResourceAttr("data.stackit_security_group.security_group", "name", securityGroupResource["name"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "labels.label1", securityGroupResource["label1"]), + resource.TestCheckResourceAttr("data.stackit_security_group.security_group", "description", securityGroupResource["description"]), + + // Security Group Rule + resource.TestCheckResourceAttrPair( + "stackit_security_group_rule.security_group_rule", "project_id", + "stackit_security_group.security_group", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_security_group_rule.security_group_rule", "security_group_id", + "stackit_security_group.security_group", "security_group_id", + ), + resource.TestCheckResourceAttrSet("stackit_security_group_rule.security_group_rule", "security_group_rule_id"), + resource.TestCheckResourceAttr("data.stackit_security_group_rule.security_group_rule", "direction", securityGroupRuleResource["direction"]), + resource.TestCheckResourceAttr("data.stackit_security_group_rule.security_group_rule", "description", securityGroupRuleResource["description"]), + ), + }, + // Import + { + ResourceName: "stackit_security_group.security_group", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_security_group.security_group"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_security_group.security_group") + } + securityGroupId, ok := r.Primary.Attributes["security_group_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute security_group_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, securityGroupId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "stackit_security_group_rule.security_group_rule", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_security_group_rule.security_group_rule"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_security_group_rule.security_group_rule") + } + securityGroupId, ok := r.Primary.Attributes["security_group_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute security_group_id") + } + securityGroupRuleId, ok := r.Primary.Attributes["security_group_rule_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute security_group_rule_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, securityGroupId, securityGroupRuleId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: resourceConfigSecurityGroup( + fmt.Sprintf("%s-updated", securityGroupResource["name"]), + securityGroupRuleResource["direction"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_security_group.security_group", "project_id", securityGroupResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_security_group.security_group", "security_group_id"), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "name", fmt.Sprintf("%s-updated", securityGroupResource["name"])), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "labels.label1", securityGroupResource["label1"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "description", securityGroupResource["description"]), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccPublicIp(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIaaSPublicIpDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: testAccPublicIpConfig( + fmt.Sprintf(` + resource "stackit_public_ip" "public_ip" { + project_id = "%s" + labels = { + "label1" = "%s" + } + network_interface_id = "%s" + } + `, + publicIpResource["project_id"], + publicIpResource["label1"], + publicIpResource["network_interface_id"], + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_public_ip.public_ip", "project_id", publicIpResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_public_ip.public_ip", "public_ip_id"), + resource.TestCheckResourceAttr("stackit_public_ip.public_ip", "labels.label1", publicIpResource["label1"]), + resource.TestCheckResourceAttr("stackit_public_ip.public_ip", "network_interface_id", publicIpResource["network_interface_id"]), + ), + }, + // Data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_public_ip" "public_ip" { + project_id = stackit_public_ip.public_ip.project_id + public_ip_id = stackit_public_ip.public_ip.public_ip_id + } + `, + testAccPublicIpConfig( + fmt.Sprintf(` + resource "stackit_public_ip" "public_ip" { + project_id = "%s" + labels = { + "label1" = "%s" + } + network_interface_id = "%s" + } + `, + publicIpResource["project_id"], + publicIpResource["label1"], + publicIpResource["network_interface_id"], + ), + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_public_ip.public_ip", "project_id", publicIpResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_public_ip.public_ip", "public_ip_id", + "data.stackit_public_ip.public_ip", "public_ip_id", + ), + resource.TestCheckResourceAttr("stackit_public_ip.public_ip", "labels.label1", publicIpResource["label1"]), + resource.TestCheckResourceAttr("data.stackit_public_ip.public_ip", "network_interface_id", publicIpResource["network_interface_id"]), + ), + }, + // Import + { + ResourceName: "stackit_public_ip.public_ip", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_public_ip.public_ip"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_public_ip.public_ip") + } + publicIpId, ok := r.Primary.Attributes["public_ip_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute public_ip_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, publicIpId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: testAccPublicIpConfig( + fmt.Sprintf(` + resource "stackit_public_ip" "public_ip" { + project_id = "%s" + labels = { + "label1" = "%s" + } + } + `, + publicIpResource["project_id"], + publicIpResource["label1"], + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_public_ip.public_ip", "project_id", publicIpResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_public_ip.public_ip", "public_ip_id"), + resource.TestCheckResourceAttr("stackit_public_ip.public_ip", "labels.label1", publicIpResource["label1"]), + resource.TestCheckNoResourceAttr("stackit_public_ip.public_ip", "network_interface_id"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testAccCheckNetworkAreaDestroy(s *terraform.State) error { ctx := context.Background() var client *iaas.APIClient var err error @@ -313,34 +991,6 @@ func testAccCheckIaaSDestroy(s *terraform.State) error { return fmt.Errorf("creating client: %w", err) } - networksToDestroy := []string{} - for _, rs := range s.RootModule().Resources { - if rs.Type != "stackit_network" { - continue - } - // network terraform ID: "[project_id],[network_id]" - networkId := strings.Split(rs.Primary.ID, core.Separator)[1] - networksToDestroy = append(networksToDestroy, networkId) - } - - networksResp, err := client.ListNetworksExecute(ctx, testutil.ProjectId) - if err != nil { - return fmt.Errorf("getting networksResp: %w", err) - } - - networks := *networksResp.Items - for i := range networks { - if networks[i].NetworkId == nil { - continue - } - if utils.Contains(networksToDestroy, *networks[i].NetworkId) { - err := client.DeleteNetworkExecute(ctx, testutil.ProjectId, *networks[i].NetworkId) - if err != nil { - return fmt.Errorf("destroying network %s during CheckDestroy: %w", *networks[i].NetworkId, err) - } - } - } - // network areas networkAreasToDestroy := []string{} for _, rs := range s.RootModule().Resources { @@ -370,3 +1020,232 @@ func testAccCheckIaaSDestroy(s *terraform.State) error { } return nil } + +func testAccCheckIaaSVolumeDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaas.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaas.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + volumesToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_volume" { + continue + } + // volume terraform ID: "[project_id],[volume_id]" + volumeId := strings.Split(rs.Primary.ID, core.Separator)[1] + volumesToDestroy = append(volumesToDestroy, volumeId) + } + + volumesResp, err := client.ListVolumesExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting volumesResp: %w", err) + } + + volumes := *volumesResp.Items + for i := range volumes { + if volumes[i].Id == nil { + continue + } + if utils.Contains(volumesToDestroy, *volumes[i].Id) { + err := client.DeleteVolumeExecute(ctx, testutil.ProjectId, *volumes[i].Id) + if err != nil { + return fmt.Errorf("destroying volume %s during CheckDestroy: %w", *volumes[i].Id, err) + } + } + } + return nil +} + +func testAccCheckServerDestroy(s *terraform.State) error { + ctx := context.Background() + var alphaClient *iaas.APIClient + var client *iaas.APIClient + var err error + var alphaErr error + if testutil.IaaSCustomEndpoint == "" { + alphaClient, alphaErr = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + alphaClient, alphaErr = iaas.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } + if err != nil || alphaErr != nil { + return fmt.Errorf("creating client: %w, %w", err, alphaErr) + } + + // Servers + + serversToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_server" { + continue + } + // server terraform ID: "[project_id],[server_id]" + serverId := strings.Split(rs.Primary.ID, core.Separator)[1] + serversToDestroy = append(serversToDestroy, serverId) + } + + serversResp, err := alphaClient.ListServersExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting serversResp: %w", err) + } + + servers := *serversResp.Items + for i := range servers { + if servers[i].Id == nil { + continue + } + if utils.Contains(serversToDestroy, *servers[i].Id) { + err := alphaClient.DeleteServerExecute(ctx, testutil.ProjectId, *servers[i].Id) + if err != nil { + return fmt.Errorf("destroying server %s during CheckDestroy: %w", *servers[i].Id, err) + } + } + } + + // Networks + + networksToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_network" { + continue + } + // network terraform ID: "[project_id],[network_id]" + networkId := strings.Split(rs.Primary.ID, core.Separator)[1] + networksToDestroy = append(networksToDestroy, networkId) + } + + networksResp, err := client.ListNetworksExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting networksResp: %w", err) + } + + networks := *networksResp.Items + for i := range networks { + if networks[i].NetworkId == nil { + continue + } + if utils.Contains(networksToDestroy, *networks[i].NetworkId) { + err := client.DeleteNetworkExecute(ctx, testutil.ProjectId, *networks[i].NetworkId) + if err != nil { + return fmt.Errorf("destroying network %s during CheckDestroy: %w", *networks[i].NetworkId, err) + } + } + } + + return nil +} + +func testAccCheckIaaSSecurityGroupDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaas.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaas.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + securityGroupsToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_security_group" { + continue + } + // security group terraform ID: "[project_id],[security_group_id]" + securityGroupId := strings.Split(rs.Primary.ID, core.Separator)[1] + securityGroupsToDestroy = append(securityGroupsToDestroy, securityGroupId) + } + + securityGroupsResp, err := client.ListSecurityGroupsExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting securityGroupsResp: %w", err) + } + + securityGroups := *securityGroupsResp.Items + for i := range securityGroups { + if securityGroups[i].Id == nil { + continue + } + if utils.Contains(securityGroupsToDestroy, *securityGroups[i].Id) { + err := client.DeleteSecurityGroupExecute(ctx, testutil.ProjectId, *securityGroups[i].Id) + if err != nil { + return fmt.Errorf("destroying security group %s during CheckDestroy: %w", *securityGroups[i].Id, err) + } + } + } + return nil +} + +func testAccCheckIaaSPublicIpDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaas.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaas.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + publicIpsToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_public_ip" { + continue + } + // public IP terraform ID: "[project_id],[public_ip_id]" + publicIpId := strings.Split(rs.Primary.ID, core.Separator)[1] + publicIpsToDestroy = append(publicIpsToDestroy, publicIpId) + } + + publicIpsResp, err := client.ListPublicIPsExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting publicIpsResp: %w", err) + } + + publicIps := *publicIpsResp.Items + for i := range publicIps { + if publicIps[i].Id == nil { + continue + } + if utils.Contains(publicIpsToDestroy, *publicIps[i].Id) { + err := client.DeletePublicIPExecute(ctx, testutil.ProjectId, *publicIps[i].Id) + if err != nil { + return fmt.Errorf("destroying public IP %s during CheckDestroy: %w", *publicIps[i].Id, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/iaas/network/datasource.go b/stackit/internal/services/iaas/network/datasource.go index 0890df50..bb35d7b2 100644 --- a/stackit/internal/services/iaas/network/datasource.go +++ b/stackit/internal/services/iaas/network/datasource.go @@ -124,6 +124,11 @@ func (d *networkDataSource) Schema(_ context.Context, _ datasource.SchemaRequest Description: "The public IP of the network.", Computed: true, }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, }, } } diff --git a/stackit/internal/services/iaas/network/resource.go b/stackit/internal/services/iaas/network/resource.go index f9a97762..0b4cbad0 100644 --- a/stackit/internal/services/iaas/network/resource.go +++ b/stackit/internal/services/iaas/network/resource.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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" @@ -42,6 +43,7 @@ type Model struct { IPv4PrefixLength types.Int64 `tfsdk:"ipv4_prefix_length"` Prefixes types.List `tfsdk:"prefixes"` PublicIP types.String `tfsdk:"public_ip"` + Labels types.Map `tfsdk:"labels"` } // NewNetworkResource is a helper function to simplify the provider implementation. @@ -160,6 +162,11 @@ func (r *networkResource) Schema(_ context.Context, _ resource.SchemaRequest, re Description: "The public IP of the network.", Computed: true, }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, }, } } @@ -178,7 +185,7 @@ func (r *networkResource) Create(ctx context.Context, req resource.CreateRequest ctx = tflog.SetField(ctx, "project_id", projectId) // Generate API request body from model - payload, err := toCreatePayload(&model) + payload, err := toCreatePayload(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network", fmt.Sprintf("Creating API payload: %v", err)) return @@ -269,8 +276,16 @@ func (r *networkResource) Update(ctx context.Context, req resource.UpdateRequest ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "network_id", networkId) + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + // Generate API request body from model - payload, err := toUpdatePayload(&model) + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network", fmt.Sprintf("Creating API payload: %v", err)) return @@ -378,6 +393,20 @@ func mapFields(ctx context.Context, networkResp *iaas.Network, model *Model) err strings.Join(idParts, core.Separator), ) + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if networkResp.Labels != nil && len(*networkResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *networkResp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + if networkResp.Nameservers == nil { model.Nameservers = types.ListNull(types.StringType) } else { @@ -412,11 +441,12 @@ func mapFields(ctx context.Context, networkResp *iaas.Network, model *Model) err model.NetworkId = types.StringValue(networkId) model.Name = types.StringPointerValue(networkResp.Name) model.PublicIP = types.StringPointerValue(networkResp.PublicIp) + model.Labels = labels return nil } -func toCreatePayload(model *Model) (*iaas.CreateNetworkPayload, error) { +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -430,6 +460,11 @@ func toCreatePayload(model *Model) (*iaas.CreateNetworkPayload, error) { modelNameservers = append(modelNameservers, nameserverString.ValueString()) } + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + return &iaas.CreateNetworkPayload{ Name: conversion.StringValueToPointer(model.Name), AddressFamily: &iaas.CreateNetworkAddressFamily{ @@ -438,10 +473,11 @@ func toCreatePayload(model *Model) (*iaas.CreateNetworkPayload, error) { Nameservers: &modelNameservers, }, }, + Labels: &labels, }, nil } -func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkPayload, error) { +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.PartialUpdateNetworkPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -455,6 +491,11 @@ func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkPayload, error) { modelNameservers = append(modelNameservers, nameserverString.ValueString()) } + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + return &iaas.PartialUpdateNetworkPayload{ Name: conversion.StringValueToPointer(model.Name), AddressFamily: &iaas.UpdateNetworkAddressFamily{ @@ -462,5 +503,6 @@ func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkPayload, error) { Nameservers: &modelNameservers, }, }, + Labels: &labels, }, nil } diff --git a/stackit/internal/services/iaas/network/resource_test.go b/stackit/internal/services/iaas/network/resource_test.go index 945ff185..4986b016 100644 --- a/stackit/internal/services/iaas/network/resource_test.go +++ b/stackit/internal/services/iaas/network/resource_test.go @@ -37,6 +37,7 @@ func TestMapFields(t *testing.T) { IPv4PrefixLength: types.Int64Null(), Prefixes: types.ListNull(types.StringType), PublicIP: types.StringNull(), + Labels: types.MapNull(types.StringType), }, true, }, @@ -58,6 +59,9 @@ func TestMapFields(t *testing.T) { "prefix2", }, PublicIp: utils.Ptr("publicIp"), + Labels: &map[string]interface{}{ + "key": "value", + }, }, Model{ Id: types.StringValue("pid,nid"), @@ -74,6 +78,9 @@ func TestMapFields(t *testing.T) { types.StringValue("prefix2"), }), PublicIP: types.StringValue("publicIp"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), }, true, }, @@ -104,6 +111,7 @@ func TestMapFields(t *testing.T) { types.StringValue("ns2"), types.StringValue("ns3"), }), + Labels: types.MapNull(types.StringType), }, true, }, @@ -135,6 +143,7 @@ func TestMapFields(t *testing.T) { types.StringValue("prefix2"), types.StringValue("prefix3"), }), + Labels: types.MapNull(types.StringType), }, true, }, @@ -190,6 +199,9 @@ func TestToCreatePayload(t *testing.T) { types.StringValue("ns2"), }), IPv4PrefixLength: types.Int64Value(24), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), }, &iaas.CreateNetworkPayload{ Name: utils.Ptr("name"), @@ -202,13 +214,16 @@ func TestToCreatePayload(t *testing.T) { PrefixLength: utils.Ptr(int64(24)), }, }, + Labels: &map[string]interface{}{ + "key": "value", + }, }, true, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - output, err := toCreatePayload(tt.input) + output, err := toCreatePayload(context.Background(), tt.input) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -240,6 +255,9 @@ func TestToUpdatePayload(t *testing.T) { types.StringValue("ns1"), types.StringValue("ns2"), }), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), }, &iaas.PartialUpdateNetworkPayload{ Name: utils.Ptr("name"), @@ -251,13 +269,16 @@ func TestToUpdatePayload(t *testing.T) { }, }, }, + Labels: &map[string]interface{}{ + "key": "value", + }, }, true, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - output, err := toUpdatePayload(tt.input) + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/services/iaas/networkarea/datasource.go b/stackit/internal/services/iaas/networkarea/datasource.go index 5af89f50..88dec746 100644 --- a/stackit/internal/services/iaas/networkarea/datasource.go +++ b/stackit/internal/services/iaas/networkarea/datasource.go @@ -185,6 +185,11 @@ func (d *networkAreaDataSource) Schema(_ context.Context, _ datasource.SchemaReq int64validator.AtMost(29), }, }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, }, } } diff --git a/stackit/internal/services/iaas/networkarea/resource.go b/stackit/internal/services/iaas/networkarea/resource.go index ed396626..ff3ab4b3 100644 --- a/stackit/internal/services/iaas/networkarea/resource.go +++ b/stackit/internal/services/iaas/networkarea/resource.go @@ -56,6 +56,7 @@ type Model struct { DefaultPrefixLength types.Int64 `tfsdk:"default_prefix_length"` MaxPrefixLength types.Int64 `tfsdk:"max_prefix_length"` MinPrefixLength types.Int64 `tfsdk:"min_prefix_length"` + Labels types.Map `tfsdk:"labels"` } // Struct corresponding to Model.NetworkRanges[i] @@ -133,12 +134,12 @@ func (r *networkAreaResource) Configure(ctx context.Context, req resource.Config // 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.", + Description: "Network area resource schema. Must have a `region` specified in the provider configuration.", + MarkdownDescription: features.AddBetaDescription("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, + Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`\".", + Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -245,6 +246,11 @@ func (r *networkAreaResource) Schema(_ context.Context, _ resource.SchemaRequest }, Default: int64default.StaticInt64(24), }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, }, } } @@ -365,8 +371,16 @@ func (r *networkAreaResource) Update(ctx context.Context, req resource.UpdateReq } } + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + // Generate API request body from model - payload, err := toUpdatePayload(&model) + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area", fmt.Sprintf("Creating API payload: %v", err)) return @@ -431,8 +445,19 @@ func (r *networkAreaResource) Delete(ctx context.Context, req resource.DeleteReq ctx = tflog.SetField(ctx, "organization_id", organizationId) ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) + projects, err := r.client.ListNetworkAreaProjects(ctx, organizationId, networkAreaId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area", fmt.Sprintf("Calling API to get the list of projects: %v", err)) + return + } + + if projects != nil && len(*projects.Items) > 0 { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area", fmt.Sprintln("You still have projects attached to the network area. Please delete or remove them from the network area before deleting the network area.")) + return + } + // Delete existing network - err := r.client.DeleteNetworkArea(ctx, organizationId, networkAreaId).Execute() + 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 @@ -518,9 +543,24 @@ func mapFields(ctx context.Context, networkAreaResp *iaas.NetworkArea, networkAr return fmt.Errorf("mapping network ranges: %w", err) } + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if networkAreaResp.Labels != nil && len(*networkAreaResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *networkAreaResp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + model.NetworkAreaId = types.StringValue(networkAreaId) model.Name = types.StringPointerValue(networkAreaResp.Name) model.ProjectCount = types.Int64PointerValue(networkAreaResp.ProjectCount) + model.Labels = labels if networkAreaResp.Ipv4 != nil { model.TransferNetwork = types.StringPointerValue(networkAreaResp.Ipv4.TransferNetwork) @@ -616,6 +656,11 @@ func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkArea return nil, fmt.Errorf("converting network ranges: %w", err) } + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + return &iaas.CreateNetworkAreaPayload{ Name: conversion.StringValueToPointer(model.Name), AddressFamily: &iaas.CreateAreaAddressFamily{ @@ -628,10 +673,11 @@ func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkArea MinPrefixLen: conversion.Int64ValueToPointer(model.MinPrefixLength), }, }, + Labels: &labels, }, nil } -func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkAreaPayload, error) { +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.PartialUpdateNetworkAreaPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -645,6 +691,11 @@ func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkAreaPayload, error modelDefaultNameservers = append(modelDefaultNameservers, nameserverString.ValueString()) } + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + return &iaas.PartialUpdateNetworkAreaPayload{ Name: conversion.StringValueToPointer(model.Name), AddressFamily: &iaas.UpdateAreaAddressFamily{ @@ -655,6 +706,7 @@ func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkAreaPayload, error MinPrefixLen: conversion.Int64ValueToPointer(model.MinPrefixLength), }, }, + Labels: &labels, }, nil } diff --git a/stackit/internal/services/iaas/networkarea/resource_test.go b/stackit/internal/services/iaas/networkarea/resource_test.go index b6427e2f..81320270 100644 --- a/stackit/internal/services/iaas/networkarea/resource_test.go +++ b/stackit/internal/services/iaas/networkarea/resource_test.go @@ -86,6 +86,7 @@ func TestMapFields(t *testing.T) { "prefix": types.StringValue("prefix-2"), }), }), + Labels: types.MapNull(types.StringType), }, true, }, @@ -118,6 +119,9 @@ func TestMapFields(t *testing.T) { MinPrefixLen: utils.Ptr(int64(18)), }, Name: utils.Ptr("name"), + Labels: &map[string]interface{}{ + "key": "value", + }, }, &[]iaas.NetworkRange{ { @@ -152,6 +156,9 @@ func TestMapFields(t *testing.T) { "prefix": types.StringValue("prefix-2"), }), }), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), }, true, }, @@ -226,6 +233,7 @@ func TestMapFields(t *testing.T) { "prefix": types.StringValue("prefix-3"), }), }), + Labels: types.MapNull(types.StringType), }, true, }, @@ -286,6 +294,7 @@ func TestMapFields(t *testing.T) { "prefix": types.StringValue("prefix-2"), }), }), + Labels: types.MapNull(types.StringType), }, true, }, @@ -334,6 +343,7 @@ func TestMapFields(t *testing.T) { "prefix": types.StringValue("prefix-3"), }), }), + Labels: types.MapNull(types.StringType), }, true, }, @@ -412,6 +422,9 @@ func TestToCreatePayload(t *testing.T) { DefaultPrefixLength: types.Int64Value(20), MaxPrefixLength: types.Int64Value(22), MinPrefixLength: types.Int64Value(18), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), }, &iaas.CreateNetworkAreaPayload{ Name: utils.Ptr("name"), @@ -435,6 +448,9 @@ func TestToCreatePayload(t *testing.T) { MinPrefixLen: utils.Ptr(int64(18)), }, }, + Labels: &map[string]interface{}{ + "key": "value", + }, }, true, }, @@ -476,6 +492,9 @@ func TestToUpdatePayload(t *testing.T) { DefaultPrefixLength: types.Int64Value(22), MaxPrefixLength: types.Int64Value(24), MinPrefixLength: types.Int64Value(20), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), }, &iaas.PartialUpdateNetworkAreaPayload{ Name: utils.Ptr("name"), @@ -490,13 +509,16 @@ func TestToUpdatePayload(t *testing.T) { MinPrefixLen: utils.Ptr(int64(20)), }, }, + Labels: &map[string]interface{}{ + "key": "value", + }, }, true, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - output, err := toUpdatePayload(tt.input) + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/services/iaas/networkarearoute/datasource.go b/stackit/internal/services/iaas/networkarearoute/datasource.go index 108c07c8..42cb70db 100644 --- a/stackit/internal/services/iaas/networkarearoute/datasource.go +++ b/stackit/internal/services/iaas/networkarearoute/datasource.go @@ -8,6 +8,7 @@ import ( "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" @@ -127,6 +128,11 @@ func (d *networkAreaRouteDataSource) Schema(_ context.Context, _ datasource.Sche Description: "The network, that is reachable though the Next Hop. Should use CIDR notation.", Computed: true, }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, }, } } @@ -157,7 +163,7 @@ func (d *networkAreaRouteDataSource) Read(ctx context.Context, req datasource.Re return } - err = mapFields(networkAreaRouteResp, &model) + err = mapFields(ctx, networkAreaRouteResp, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route", fmt.Sprintf("Processing API payload: %v", err)) return diff --git a/stackit/internal/services/iaas/networkarearoute/resource.go b/stackit/internal/services/iaas/networkarearoute/resource.go index 7935ee2e..cb1c91bf 100644 --- a/stackit/internal/services/iaas/networkarearoute/resource.go +++ b/stackit/internal/services/iaas/networkarearoute/resource.go @@ -6,9 +6,11 @@ import ( "net/http" "strings" + "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/mapplanmodifier" "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" @@ -42,6 +44,7 @@ type Model struct { NetworkAreaRouteId types.String `tfsdk:"network_area_route_id"` NextHop types.String `tfsdk:"next_hop"` Prefix types.String `tfsdk:"prefix"` + Labels types.Map `tfsdk:"labels"` } // NewNetworkAreaRouteResource is a helper function to simplify the provider implementation. @@ -107,12 +110,12 @@ func (r *networkAreaRouteResource) Configure(ctx context.Context, req resource.C // 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.", + Description: "Network area route resource schema. Must have a `region` specified in the provider configuration.", + MarkdownDescription: features.AddBetaDescription("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, + Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`,`network_area_route_id`\".", + Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, @@ -172,6 +175,14 @@ func (r *networkAreaRouteResource) Schema(_ context.Context, _ resource.SchemaRe validate.CIDR(), }, }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.RequiresReplace(), + }, + }, }, } } @@ -192,7 +203,7 @@ func (r *networkAreaRouteResource) Create(ctx context.Context, req resource.Crea ctx = tflog.SetField(ctx, "network_area_id", networkAreaId) // Generate API request body from model - payload, err := toCreatePayload(&model) + payload, err := toCreatePayload(ctx, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route", fmt.Sprintf("Creating API payload: %v", err)) return @@ -222,7 +233,7 @@ func (r *networkAreaRouteResource) Create(ctx context.Context, req resource.Crea ctx = tflog.SetField(ctx, "network_area_route_id", routeId) // Map response body to schema - err = mapFields(&route, &model) + err = mapFields(ctx, &route, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route.", fmt.Sprintf("Processing API payload: %v", err)) return @@ -263,7 +274,7 @@ func (r *networkAreaRouteResource) Read(ctx context.Context, req resource.ReadRe } // Map response body to schema - err = mapFields(networkAreaRouteResp, &model) + err = mapFields(ctx, networkAreaRouteResp, &model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route", fmt.Sprintf("Processing API payload: %v", err)) return @@ -335,7 +346,7 @@ func (r *networkAreaRouteResource) ImportState(ctx context.Context, req resource tflog.Info(ctx, "Network area route state imported") } -func mapFields(networkAreaRoute *iaas.Route, model *Model) error { +func mapFields(ctx context.Context, networkAreaRoute *iaas.Route, model *Model) error { if networkAreaRoute == nil { return fmt.Errorf("response input is nil") } @@ -361,22 +372,43 @@ func mapFields(networkAreaRoute *iaas.Route, model *Model) error { strings.Join(idParts, core.Separator), ) + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if networkAreaRoute.Labels != nil && len(*networkAreaRoute.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *networkAreaRoute.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + model.NetworkAreaRouteId = types.StringValue(networkAreaRouteId) model.NextHop = types.StringPointerValue(networkAreaRoute.Nexthop) model.Prefix = types.StringPointerValue(networkAreaRoute.Prefix) + model.Labels = labels return nil } -func toCreatePayload(model *Model) (*iaas.CreateNetworkAreaRoutePayload, error) { +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkAreaRoutePayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + return &iaas.CreateNetworkAreaRoutePayload{ Ipv4: &[]iaas.Route{ { Prefix: conversion.StringValueToPointer(model.Prefix), Nexthop: conversion.StringValueToPointer(model.NextHop), + Labels: &labels, }, }, }, nil diff --git a/stackit/internal/services/iaas/networkarearoute/resource_test.go b/stackit/internal/services/iaas/networkarearoute/resource_test.go index c3fd2c4a..52dd889d 100644 --- a/stackit/internal/services/iaas/networkarearoute/resource_test.go +++ b/stackit/internal/services/iaas/networkarearoute/resource_test.go @@ -1,9 +1,11 @@ package networkarearoute import ( + "context" "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/iaas" @@ -32,6 +34,7 @@ func TestMapFields(t *testing.T) { NetworkAreaRouteId: types.StringValue("narid"), Prefix: types.StringNull(), NextHop: types.StringNull(), + Labels: types.MapNull(types.StringType), }, true, }, @@ -45,6 +48,9 @@ func TestMapFields(t *testing.T) { &iaas.Route{ Prefix: utils.Ptr("prefix"), Nexthop: utils.Ptr("hop"), + Labels: &map[string]interface{}{ + "key": "value", + }, }, Model{ Id: types.StringValue("oid,naid,narid"), @@ -53,6 +59,9 @@ func TestMapFields(t *testing.T) { NetworkAreaRouteId: types.StringValue("narid"), Prefix: types.StringValue("prefix"), NextHop: types.StringValue("hop"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), }, true, }, @@ -86,7 +95,7 @@ func TestMapFields(t *testing.T) { } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - err := mapFields(tt.input, &tt.state) + err := mapFields(context.Background(), tt.input, &tt.state) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -115,12 +124,18 @@ func TestToCreatePayload(t *testing.T) { input: &Model{ Prefix: types.StringValue("prefix"), NextHop: types.StringValue("hop"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), }, expected: &iaas.CreateNetworkAreaRoutePayload{ Ipv4: &[]iaas.Route{ { Prefix: utils.Ptr("prefix"), Nexthop: utils.Ptr("hop"), + Labels: &map[string]interface{}{ + "key": "value", + }, }, }, }, @@ -129,7 +144,7 @@ func TestToCreatePayload(t *testing.T) { } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - output, err := toCreatePayload(tt.input) + output, err := toCreatePayload(context.Background(), tt.input) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/services/iaas/networkinterface/datasource.go b/stackit/internal/services/iaas/networkinterface/datasource.go new file mode 100644 index 00000000..f754e3bd --- /dev/null +++ b/stackit/internal/services/iaas/networkinterface/datasource.go @@ -0,0 +1,206 @@ +package networkinterface + +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-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/utils" + "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 networkInterfaceDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &networkInterfaceDataSource{} +) + +// NewNetworkDataSource is a helper function to simplify the provider implementation. +func NewNetworkInterfaceDataSource() datasource.DataSource { + return &networkInterfaceDataSource{} +} + +// networkInterfaceDataSource is the data source implementation. +type networkInterfaceDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *networkInterfaceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_network_interface" +} + +func (d *networkInterfaceDataSource) 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 !networkInterfaceDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_network_interface", "data source") + if resp.Diagnostics.HasError() { + return + } + networkInterfaceDataSourceBetaCheckDone = 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 *networkInterfaceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + typeOptions := []string{"server", "metadata", "gateway"} + + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Network interface datasource schema. Must have a `region` specified in the provider configuration."), + Description: "Network interface datasource 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 \"`project_id`,`network_id`,`network_interface_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the network interface is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_id": schema.StringAttribute{ + Description: "The network ID to which the network interface is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_interface_id": schema.StringAttribute{ + Description: "The network interface ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the network interface.", + Computed: true, + }, + "allowed_addresses": schema.ListAttribute{ + Description: "The list of CIDR (Classless Inter-Domain Routing) notations.", + Computed: true, + ElementType: types.StringType, + }, + "device": schema.StringAttribute{ + Description: "The device UUID of the network interface.", + Computed: true, + }, + "ipv4": schema.StringAttribute{ + Description: "The IPv4 address.", + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a network interface.", + ElementType: types.StringType, + Computed: true, + }, + "mac": schema.StringAttribute{ + Description: "The MAC address of network interface.", + Computed: true, + }, + "security": schema.BoolAttribute{ + Description: "The Network Interface Security. If set to false, then no security groups will apply to this network interface.", + Computed: true, + }, + "security_group_ids": schema.ListAttribute{ + Description: "The list of security group UUIDs. If security is set to false, setting this field will lead to an error.", + Computed: true, + ElementType: types.StringType, + }, + "type": schema.StringAttribute{ + Description: "Type of network interface. Some of the possible values are: " + utils.SupportedValuesDocumentation(typeOptions), + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *networkInterfaceDataSource) 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 + } + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + networkInterfaceResp, err := d.client.GetNIC(ctx, projectId, networkId, networkInterfaceId).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 interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, networkInterfaceResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", 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 interface read") +} diff --git a/stackit/internal/services/iaas/networkinterface/resource.go b/stackit/internal/services/iaas/networkinterface/resource.go new file mode 100644 index 00000000..6f3be178 --- /dev/null +++ b/stackit/internal/services/iaas/networkinterface/resource.go @@ -0,0 +1,636 @@ +package networkinterface + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/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/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 = &networkInterfaceResource{} + _ resource.ResourceWithConfigure = &networkInterfaceResource{} + _ resource.ResourceWithImportState = &networkInterfaceResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + NetworkId types.String `tfsdk:"network_id"` + NetworkInterfaceId types.String `tfsdk:"network_interface_id"` + Name types.String `tfsdk:"name"` + AllowedAddresses types.List `tfsdk:"allowed_addresses"` + IPv4 types.String `tfsdk:"ipv4"` + Labels types.Map `tfsdk:"labels"` + Security types.Bool `tfsdk:"security"` + SecurityGroupIds types.List `tfsdk:"security_group_ids"` + Device types.String `tfsdk:"device"` + Mac types.String `tfsdk:"mac"` + Type types.String `tfsdk:"type"` +} + +// NewNetworkInterfaceResource is a helper function to simplify the provider implementation. +func NewNetworkInterfaceResource() resource.Resource { + return &networkInterfaceResource{} +} + +// networkResource is the resource implementation. +type networkInterfaceResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *networkInterfaceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_network_interface" +} + +// Configure adds the provider configured client to the resource. +func (r *networkInterfaceResource) 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_interface", "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 *networkInterfaceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + typeOptions := []string{"server", "metadata", "gateway"} + + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Network interface resource schema. Must have a `region` specified in the provider configuration."), + Description: "Network interface 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 \"`project_id`,`network_id`,`network_interface_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the network is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_id": schema.StringAttribute{ + Description: "The network ID to which the network interface is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_interface_id": schema.StringAttribute{ + Description: "The network interface ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the network interface.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "allowed_addresses": schema.ListAttribute{ + Description: "The list of CIDR (Classless Inter-Domain Routing) notations.", + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + validate.CIDR(), + ), + }, + }, + "device": schema.StringAttribute{ + Description: "The device UUID of the network interface.", + Computed: true, + }, + "ipv4": schema.StringAttribute{ + Description: "The IPv4 address.", + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.IP(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a network interface.", + ElementType: types.StringType, + Optional: true, + }, + "mac": schema.StringAttribute{ + Description: "The MAC address of network interface.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "security": schema.BoolAttribute{ + Description: "The Network Interface Security. If set to false, then no security groups will apply to this network interface.", + Computed: true, + Optional: true, + }, + "security_group_ids": schema.ListAttribute{ + Description: "The list of security group UUIDs. If security is set to false, setting this field will lead to an error.", + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`), + "must match expression"), + ), + }, + }, + "type": schema.StringAttribute{ + Description: "Type of network interface. Some of the possible values are: " + utils.SupportedValuesDocumentation(typeOptions), + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *networkInterfaceResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + networkId := model.NetworkId.ValueString() + ctx = tflog.SetField(ctx, "network_id", networkId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new network interface + networkInterface, err := r.client.CreateNIC(ctx, projectId, networkId).CreateNICPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + networkInterfaceId := *networkInterface.Id + + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + // Map response body to schema + err = mapFields(ctx, networkInterface, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", 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 interface created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *networkInterfaceResource) 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 + } + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + networkInterfaceResp, err := r.client.GetNIC(ctx, projectId, networkId, networkInterfaceId).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 interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, networkInterfaceResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", 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 interface read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *networkInterfaceResource) 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 + } + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing network + nicResp, err := r.client.UpdateNIC(ctx, projectId, networkId, networkInterfaceId).UpdateNICPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, nicResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", 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 interface updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *networkInterfaceResource) 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 + } + + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + // Delete existing network interface + err := r.client.DeleteNIC(ctx, projectId, networkId, networkInterfaceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "Network interface deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,network_id,network_interface_id +func (r *networkInterfaceResource) 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 interface", + fmt.Sprintf("Expected import identifier with format: [project_id],[network_id],[network_interface_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + networkId := idParts[1] + networkInterfaceId := idParts[2] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_id"), networkId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_interface_id"), networkInterfaceId)...) + tflog.Info(ctx, "Network interface state imported") +} + +func mapFields(ctx context.Context, networkInterfaceResp *iaas.NIC, model *Model) error { + if networkInterfaceResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var networkInterfaceId string + if model.NetworkInterfaceId.ValueString() != "" { + networkInterfaceId = model.NetworkInterfaceId.ValueString() + } else if networkInterfaceResp.NetworkId != nil { + networkInterfaceId = *networkInterfaceResp.Id + } else { + return fmt.Errorf("network interface id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + model.NetworkId.ValueString(), + networkInterfaceId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + respAllowedAddresses := []string{} + var diags diag.Diagnostics + if networkInterfaceResp.AllowedAddresses == nil { + model.AllowedAddresses = types.ListNull(types.StringType) + } else { + for _, n := range *networkInterfaceResp.AllowedAddresses { + respAllowedAddresses = append(respAllowedAddresses, *n.String) + } + + modelAllowedAddresses, err := utils.ListValuetoStringSlice(model.AllowedAddresses) + if err != nil { + return fmt.Errorf("get current network interface allowed addresses from model: %w", err) + } + + reconciledAllowedAddresses := utils.ReconcileStringSlices(modelAllowedAddresses, respAllowedAddresses) + + allowedAddressesTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledAllowedAddresses) + if diags.HasError() { + return fmt.Errorf("map network interface allowed addresses: %w", core.DiagsToError(diags)) + } + + model.AllowedAddresses = allowedAddressesTF + } + + if networkInterfaceResp.SecurityGroups == nil { + model.SecurityGroupIds = types.ListNull(types.StringType) + } else { + respSecurityGroups := *networkInterfaceResp.SecurityGroups + modelSecurityGroups, err := utils.ListValuetoStringSlice(model.SecurityGroupIds) + if err != nil { + return fmt.Errorf("get current network interface security groups from model: %w", err) + } + + reconciledSecurityGroups := utils.ReconcileStringSlices(modelSecurityGroups, respSecurityGroups) + + securityGroupsTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledSecurityGroups) + if diags.HasError() { + return fmt.Errorf("map network interface security groups: %w", core.DiagsToError(diags)) + } + + model.SecurityGroupIds = securityGroupsTF + } + + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if networkInterfaceResp.Labels != nil && len(*networkInterfaceResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *networkInterfaceResp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + + model.NetworkInterfaceId = types.StringValue(networkInterfaceId) + model.Name = types.StringPointerValue(networkInterfaceResp.Name) + model.IPv4 = types.StringPointerValue(networkInterfaceResp.Ipv4) + model.Security = types.BoolPointerValue(networkInterfaceResp.NicSecurity) + model.Device = types.StringPointerValue(networkInterfaceResp.Device) + model.Mac = types.StringPointerValue(networkInterfaceResp.Mac) + model.Type = types.StringPointerValue(networkInterfaceResp.Type) + model.Labels = labels + + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNICPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + var labelPayload *map[string]interface{} + + modelSecurityGroups := []string{} + if !(model.SecurityGroupIds.IsNull() || model.SecurityGroupIds.IsUnknown()) { + for _, ns := range model.SecurityGroupIds.Elements() { + securityGroupString, ok := ns.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + modelSecurityGroups = append(modelSecurityGroups, securityGroupString.ValueString()) + } + } + + allowedAddressesPayload := []iaas.AllowedAddressesInner{} + + if !(model.AllowedAddresses.IsNull() || model.AllowedAddresses.IsUnknown()) { + for _, allowedAddressModel := range model.AllowedAddresses.Elements() { + allowedAddressString, ok := allowedAddressModel.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + + allowedAddressesPayload = append(allowedAddressesPayload, iaas.AllowedAddressesInner{ + String: conversion.StringValueToPointer(allowedAddressString), + }) + } + } + + if !model.Labels.IsNull() && !model.Labels.IsUnknown() { + labelMap, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("mapping labels: %w", err) + } + labelPayload = &labelMap + } + + return &iaas.CreateNICPayload{ + AllowedAddresses: &allowedAddressesPayload, + SecurityGroups: &modelSecurityGroups, + Labels: labelPayload, + Name: conversion.StringValueToPointer(model.Name), + Device: conversion.StringValueToPointer(model.Device), + Ipv4: conversion.StringValueToPointer(model.IPv4), + Mac: conversion.StringValueToPointer(model.Mac), + Type: conversion.StringValueToPointer(model.Type), + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateNICPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + var labelPayload *map[string]interface{} + + modelSecurityGroups := []string{} + for _, ns := range model.SecurityGroupIds.Elements() { + securityGroupString, ok := ns.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + modelSecurityGroups = append(modelSecurityGroups, securityGroupString.ValueString()) + } + + allowedAddressesPayload := []iaas.AllowedAddressesInner{} + + if !(model.AllowedAddresses.IsNull() || model.AllowedAddresses.IsUnknown()) { + for _, allowedAddressModel := range model.AllowedAddresses.Elements() { + allowedAddressString, ok := allowedAddressModel.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + + allowedAddressesPayload = append(allowedAddressesPayload, iaas.AllowedAddressesInner{ + String: conversion.StringValueToPointer(allowedAddressString), + }) + } + } + + if !model.Labels.IsNull() && !model.Labels.IsUnknown() { + labelMap, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("mapping labels: %w", err) + } + labelPayload = &labelMap + } + + return &iaas.UpdateNICPayload{ + AllowedAddresses: &allowedAddressesPayload, + SecurityGroups: &modelSecurityGroups, + Labels: labelPayload, + Name: conversion.StringValueToPointer(model.Name), + }, nil +} diff --git a/stackit/internal/services/iaas/networkinterface/resource_test.go b/stackit/internal/services/iaas/networkinterface/resource_test.go new file mode 100644 index 00000000..0deee47a --- /dev/null +++ b/stackit/internal/services/iaas/networkinterface/resource_test.go @@ -0,0 +1,273 @@ +package networkinterface + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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.NIC + expected Model + isValid bool + }{ + { + "id_ok", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + &iaas.NIC{ + Id: utils.Ptr("nicid"), + }, + Model{ + Id: types.StringValue("pid,nid,nicid"), + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + Name: types.StringNull(), + AllowedAddresses: types.ListNull(types.StringType), + SecurityGroupIds: types.ListNull(types.StringType), + IPv4: types.StringNull(), + Security: types.BoolNull(), + Device: types.StringNull(), + Mac: types.StringNull(), + Type: types.StringNull(), + Labels: types.MapNull(types.StringType), + }, + true, + }, + { + "values_ok", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + &iaas.NIC{ + Id: utils.Ptr("nicid"), + Name: utils.Ptr("name"), + AllowedAddresses: &[]iaas.AllowedAddressesInner{ + { + String: utils.Ptr("aa1"), + }, + }, + SecurityGroups: &[]string{ + "prefix1", + "prefix2", + }, + Ipv4: utils.Ptr("ipv4"), + Ipv6: utils.Ptr("ipv6"), + NicSecurity: utils.Ptr(true), + Device: utils.Ptr("device"), + Mac: utils.Ptr("mac"), + Status: utils.Ptr("status"), + Type: utils.Ptr("type"), + Labels: &map[string]interface{}{ + "label1": "ref1", + }, + }, + Model{ + Id: types.StringValue("pid,nid,nicid"), + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + Name: types.StringValue("name"), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa1"), + }), + SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("prefix1"), + types.StringValue("prefix2"), + }), + IPv4: types.StringValue("ipv4"), + Security: types.BoolValue(true), + Device: types.StringValue("device"), + Mac: types.StringValue("mac"), + Type: types.StringValue("type"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"label1": types.StringValue("ref1")}), + }, + true, + }, + { + "allowed_addresses_changed_outside_tf", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa1"), + }), + }, + &iaas.NIC{ + Id: utils.Ptr("nicid"), + AllowedAddresses: &[]iaas.AllowedAddressesInner{ + { + String: utils.Ptr("aa2"), + }, + }, + }, + Model{ + Id: types.StringValue("pid,nid,nicid"), + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + Name: types.StringNull(), + SecurityGroupIds: types.ListNull(types.StringType), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa2"), + }), + Labels: types.MapNull(types.StringType), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaas.NIC{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), 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.CreateNICPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("sg1"), + types.StringValue("sg2"), + }), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa1"), + }), + }, + &iaas.CreateNICPayload{ + Name: utils.Ptr("name"), + SecurityGroups: &[]string{ + "sg1", + "sg2", + }, + AllowedAddresses: &[]iaas.AllowedAddressesInner{ + { + String: utils.Ptr("aa1"), + }, + }, + }, + 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.UpdateNICPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("sg1"), + types.StringValue("sg2"), + }), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa1"), + }), + }, + &iaas.UpdateNICPayload{ + Name: utils.Ptr("name"), + SecurityGroups: &[]string{ + "sg1", + "sg2", + }, + AllowedAddresses: &[]iaas.AllowedAddressesInner{ + { + String: utils.Ptr("aa1"), + }, + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + 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/services/iaas/networkinterfaceattach/resource.go b/stackit/internal/services/iaas/networkinterfaceattach/resource.go new file mode 100644 index 00000000..c2b353b5 --- /dev/null +++ b/stackit/internal/services/iaas/networkinterfaceattach/resource.go @@ -0,0 +1,303 @@ +package networkinterfaceattach + +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/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 = &networkInterfaceAttachResource{} + _ resource.ResourceWithConfigure = &networkInterfaceAttachResource{} + _ resource.ResourceWithImportState = &networkInterfaceAttachResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + ServerId types.String `tfsdk:"server_id"` + NetworkInterfaceId types.String `tfsdk:"network_interface_id"` +} + +// NewNetworkInterfaceAttachResource is a helper function to simplify the provider implementation. +func NewNetworkInterfaceAttachResource() resource.Resource { + return &networkInterfaceAttachResource{} +} + +// networkInterfaceAttachResource is the resource implementation. +type networkInterfaceAttachResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *networkInterfaceAttachResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_server_network_interface_attach" +} + +// Configure adds the provider configured client to the resource. +func (r *networkInterfaceAttachResource) 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_server_network_interface_attach", "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 *networkInterfaceAttachResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Network interface attachment resource schema. Attaches a network interface to a server. Must have a `region` specified in the provider configuration."), + Description: "Network interface attachment resource schema. Attaches a network interface to a server. 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 \"`project_id`,`server_id`,`network_interface_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the network interface attachment is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_interface_id": schema.StringAttribute{ + Description: "The network interface ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *networkInterfaceAttachResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "server_id", serverId) + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + // Create new network interface attachment + err := r.client.AddNICToServer(ctx, projectId, serverId, networkInterfaceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching network interface to server", fmt.Sprintf("Calling API: %v", err)) + return + } + + idParts := []string{ + projectId, + serverId, + networkInterfaceId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + // 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 interface attachment created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *networkInterfaceAttachResource) 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 + } + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "server_id", serverId) + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + nics, err := r.client.ListServerNICs(ctx, projectId, serverId).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 interface attachment", fmt.Sprintf("Calling API: %v", err)) + return + } + + if nics == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface attachment", "List of network interfaces attached to the server is nil") + return + } + + if nics.Items != nil { + for _, nic := range *nics.Items { + if nic.Id == nil || (nic.Id != nil && *nic.Id != networkInterfaceId) { + continue + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network interface attachment read") + return + } + } + + // no matching network interface was found, the attachment no longer exists + resp.State.RemoveResource(ctx) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *networkInterfaceAttachResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update is not supported, all fields require replace +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *networkInterfaceAttachResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "server_id", serverId) + network_interfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "network_interface_id", network_interfaceId) + + // Remove network_interface from server + err := r.client.RemoveNICFromServer(ctx, projectId, serverId, network_interfaceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing network interface from server", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "Network interface attachment deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,server_id +func (r *networkInterfaceAttachResource) 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_interface attachment", + fmt.Sprintf("Expected import identifier with format: [project_id],[server_id],[network_interface_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + serverId := idParts[1] + network_interfaceId := idParts[2] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + ctx = tflog.SetField(ctx, "network_interface_id", network_interfaceId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_interface_id"), network_interfaceId)...) + tflog.Info(ctx, "Network interface attachment state imported") +} diff --git a/stackit/internal/services/iaas/publicip/datasource.go b/stackit/internal/services/iaas/publicip/datasource.go new file mode 100644 index 00000000..f530b5a1 --- /dev/null +++ b/stackit/internal/services/iaas/publicip/datasource.go @@ -0,0 +1,171 @@ +package publicip + +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-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" +) + +// publicIpDataSourceBetaCheckDone 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 publicIpDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &publicIpDataSource{} +) + +// NewVolumeDataSource is a helper function to simplify the provider implementation. +func NewPublicIpDataSource() datasource.DataSource { + return &publicIpDataSource{} +} + +// publicIpDataSource is the data source implementation. +type publicIpDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *publicIpDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_public_ip" +} + +func (d *publicIpDataSource) 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 !publicIpDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_public_ip", "data source") + if resp.Diagnostics.HasError() { + return + } + publicIpDataSourceBetaCheckDone = 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 resource. +func (r *publicIpDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Volume resource schema. Must have a `region` specified in the provider configuration."), + Description: "Volume resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal datasource ID. It is structured as \"`project_id`,`public_ip_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the public IP is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "public_ip_id": schema.StringAttribute{ + Description: "The public IP ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "ip": schema.StringAttribute{ + Description: "The IP address.", + Computed: true, + }, + "network_interface_id": schema.StringAttribute{ + Description: "Associates the public IP with a network interface or a virtual IP (ID).", + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *publicIpDataSource) 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 + } + projectId := model.ProjectId.ValueString() + publicIpId := model.PublicIpId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "public_ip_id", publicIpId) + + publicIpResp, err := d.client.GetPublicIP(ctx, projectId, publicIpId).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 public IP", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, publicIpResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP", 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, "public IP read") +} diff --git a/stackit/internal/services/iaas/publicip/resource.go b/stackit/internal/services/iaas/publicip/resource.go new file mode 100644 index 00000000..2dd733a5 --- /dev/null +++ b/stackit/internal/services/iaas/publicip/resource.go @@ -0,0 +1,431 @@ +package publicip + +import ( + "context" + "fmt" + "net/http" + "strings" + + "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/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 = &publicIpResource{} + _ resource.ResourceWithConfigure = &publicIpResource{} + _ resource.ResourceWithImportState = &publicIpResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + PublicIpId types.String `tfsdk:"public_ip_id"` + Ip types.String `tfsdk:"ip"` + NetworkInterfaceId types.String `tfsdk:"network_interface_id"` + Labels types.Map `tfsdk:"labels"` +} + +// NewPublicIpResource is a helper function to simplify the provider implementation. +func NewPublicIpResource() resource.Resource { + return &publicIpResource{} +} + +// publicIpResource is the resource implementation. +type publicIpResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *publicIpResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_public_ip" +} + +// Configure adds the provider configured client to the resource. +func (r *publicIpResource) 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_public_ip", "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 *publicIpResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Public IP resource schema. Must have a `region` specified in the provider configuration."), + Description: "Public IP 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 \"`project_id`,`public_ip_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the public IP is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "public_ip_id": schema.StringAttribute{ + Description: "The public IP ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "ip": schema.StringAttribute{ + Description: "The IP address.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.IP(), + }, + }, + "network_interface_id": schema.StringAttribute{ + Description: "Associates the public IP with a network interface or a virtual IP (ID).", + Optional: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *publicIpResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating public IP", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new public IP + + publicIp, err := r.client.CreatePublicIP(ctx, projectId).CreatePublicIPPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating public IP", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = tflog.SetField(ctx, "public_ip_id", *publicIp.Id) + + // Map response body to schema + err = mapFields(ctx, publicIp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating public IP", 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, "Public IP created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *publicIpResource) 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 + } + projectId := model.ProjectId.ValueString() + publicIpId := model.PublicIpId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "public_ip_id", publicIpId) + + publicIpResp, err := r.client.GetPublicIP(ctx, projectId, publicIpId).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 public IP", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, publicIpResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP", 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, "public IP read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *publicIpResource) 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 + } + projectId := model.ProjectId.ValueString() + publicIpId := model.PublicIpId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "public_ip_id", publicIpId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating public IP", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing public IP + updatedPublicIp, err := r.client.UpdatePublicIP(ctx, projectId, publicIpId).UpdatePublicIPPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating public IP", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, updatedPublicIp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating public IP", 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, "public IP updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *publicIpResource) 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 + } + + projectId := model.ProjectId.ValueString() + publicIpId := model.PublicIpId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "public_ip_id", publicIpId) + + // Delete existing publicIp + err := r.client.DeletePublicIP(ctx, projectId, publicIpId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting public IP", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "public IP deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,public_ip_id +func (r *publicIpResource) 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 public IP", + fmt.Sprintf("Expected import identifier with format: [project_id],[public_ip_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + publicIpId := idParts[1] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "public_ip_id", publicIpId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("public_ip_id"), publicIpId)...) + tflog.Info(ctx, "public IP state imported") +} + +func mapFields(ctx context.Context, publicIpResp *iaas.PublicIp, model *Model) error { + if publicIpResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var publicIpId string + if model.PublicIpId.ValueString() != "" { + publicIpId = model.PublicIpId.ValueString() + } else if publicIpResp.Id != nil { + publicIpId = *publicIpResp.Id + } else { + return fmt.Errorf("public IP id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + publicIpId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if publicIpResp.Labels != nil && len(*publicIpResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *publicIpResp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + + model.PublicIpId = types.StringValue(publicIpId) + model.Ip = types.StringPointerValue(publicIpResp.Ip) + if publicIpResp.NetworkInterface != nil { + model.NetworkInterfaceId = types.StringPointerValue(publicIpResp.GetNetworkInterface()) + } else { + model.NetworkInterfaceId = types.StringNull() + } + model.Labels = labels + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreatePublicIPPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaas.CreatePublicIPPayload{ + Labels: &labels, + Ip: conversion.StringValueToPointer(model.Ip), + NetworkInterface: iaas.NewNullableString(conversion.StringValueToPointer(model.NetworkInterfaceId)), + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdatePublicIPPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaas.UpdatePublicIPPayload{ + Labels: &labels, + NetworkInterface: iaas.NewNullableString(conversion.StringValueToPointer(model.NetworkInterfaceId)), + }, nil +} diff --git a/stackit/internal/services/iaas/publicip/resource_test.go b/stackit/internal/services/iaas/publicip/resource_test.go new file mode 100644 index 00000000..1eda0e8d --- /dev/null +++ b/stackit/internal/services/iaas/publicip/resource_test.go @@ -0,0 +1,264 @@ +package publicip + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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.PublicIp + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + }, + &iaas.PublicIp{ + Id: utils.Ptr("pipid"), + NetworkInterface: iaas.NewNullableString(nil), + }, + Model{ + Id: types.StringValue("pid,pipid"), + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + Ip: types.StringNull(), + Labels: types.MapNull(types.StringType), + NetworkInterfaceId: types.StringNull(), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + }, + &iaas.PublicIp{ + Id: utils.Ptr("pipid"), + Ip: utils.Ptr("ip"), + Labels: &map[string]interface{}{ + "key": "value", + }, + NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")), + }, + Model{ + Id: types.StringValue("pid,pipid"), + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + Ip: types.StringValue("ip"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + NetworkInterfaceId: types.StringValue("interface"), + }, + true, + }, + { + "empty_labels", + Model{ + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + &iaas.PublicIp{ + Id: utils.Ptr("pipid"), + NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")), + }, + Model{ + Id: types.StringValue("pid,pipid"), + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + Ip: types.StringNull(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + NetworkInterfaceId: types.StringValue("interface"), + }, + true, + }, + { + "network_interface_id_nil", + Model{ + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + }, + &iaas.PublicIp{ + Id: utils.Ptr("pipid"), + }, + Model{ + Id: types.StringValue("pid,pipid"), + ProjectId: types.StringValue("pid"), + PublicIpId: types.StringValue("pipid"), + Ip: types.StringNull(), + Labels: types.MapNull(types.StringType), + NetworkInterfaceId: types.StringNull(), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaas.PublicIp{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), 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.CreatePublicIPPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Ip: types.StringValue("ip"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + NetworkInterfaceId: types.StringValue("interface"), + }, + &iaas.CreatePublicIPPayload{ + Ip: utils.Ptr("ip"), + Labels: &map[string]interface{}{ + "key": "value", + }, + NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")), + }, + true, + }, + { + "network_interface_nil", + &Model{ + Ip: types.StringValue("ip"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + &iaas.CreatePublicIPPayload{ + Ip: utils.Ptr("ip"), + Labels: &map[string]interface{}{ + "key": "value", + }, + NetworkInterface: iaas.NewNullableString(nil), + }, + 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, cmp.AllowUnexported(iaas.NullableString{})) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaas.UpdatePublicIPPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Ip: types.StringValue("ip"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + NetworkInterfaceId: types.StringValue("interface"), + }, + &iaas.UpdatePublicIPPayload{ + Labels: &map[string]interface{}{ + "key": "value", + }, + NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")), + }, + true, + }, + { + "network_interface_nil", + &Model{ + Ip: types.StringValue("ip"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + &iaas.UpdatePublicIPPayload{ + Labels: &map[string]interface{}{ + "key": "value", + }, + NetworkInterface: iaas.NewNullableString(nil), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + 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, cmp.AllowUnexported(iaas.NullableString{})) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/iaas/securitygroup/datasource.go b/stackit/internal/services/iaas/securitygroup/datasource.go new file mode 100644 index 00000000..33bc0f61 --- /dev/null +++ b/stackit/internal/services/iaas/securitygroup/datasource.go @@ -0,0 +1,171 @@ +package securitygroup + +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-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" +) + +// securityGroupDataSourceBetaCheckDone 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 securityGroupDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &securityGroupDataSource{} +) + +// NewSecurityGroupDataSource is a helper function to simplify the provider implementation. +func NewSecurityGroupDataSource() datasource.DataSource { + return &securityGroupDataSource{} +} + +// securityGroupDataSource is the data source implementation. +type securityGroupDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *securityGroupDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_security_group" +} + +func (d *securityGroupDataSource) 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 !securityGroupDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_security_group", "data source") + if resp.Diagnostics.HasError() { + return + } + securityGroupDataSourceBetaCheckDone = 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 resource. +func (r *securityGroupDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Security group datasource schema. Must have a `region` specified in the provider configuration."), + Description: "Security group 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 \"`project_id`,`security_group_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the security group is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "security_group_id": schema.StringAttribute{ + Description: "The security group ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the security group.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "The description of the security group.", + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, + "stateful": schema.BoolAttribute{ + Description: "Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server.", + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *securityGroupDataSource) 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 + } + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + securityGroupResp, err := d.client.GetSecurityGroup(ctx, projectId, securityGroupId).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 security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, securityGroupResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", 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, "security group read") +} diff --git a/stackit/internal/services/iaas/securitygroup/resource.go b/stackit/internal/services/iaas/securitygroup/resource.go new file mode 100644 index 00000000..2c69fe3f --- /dev/null +++ b/stackit/internal/services/iaas/securitygroup/resource.go @@ -0,0 +1,451 @@ +package securitygroup + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/boolplanmodifier" + "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 = &securityGroupResource{} + _ resource.ResourceWithConfigure = &securityGroupResource{} + _ resource.ResourceWithImportState = &securityGroupResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + SecurityGroupId types.String `tfsdk:"security_group_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Labels types.Map `tfsdk:"labels"` + Stateful types.Bool `tfsdk:"stateful"` +} + +// NewSecurityGroupResource is a helper function to simplify the provider implementation. +func NewSecurityGroupResource() resource.Resource { + return &securityGroupResource{} +} + +// securityGroupResource is the resource implementation. +type securityGroupResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *securityGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_security_group" +} + +// Configure adds the provider configured client to the resource. +func (r *securityGroupResource) 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_security_group", "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 *securityGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Security group resource schema. Must have a `region` specified in the provider configuration."), + Description: "Security group 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 \"`project_id`,`security_group_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the security group is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "security_group_id": schema.StringAttribute{ + Description: "The security group ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the security group.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "description": schema.StringAttribute{ + Description: "The description of the security group.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(127), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, + "stateful": schema.BoolAttribute{ + Description: "Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + boolplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *securityGroupResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new security group + + securityGroup, err := r.client.CreateSecurityGroup(ctx, projectId).CreateSecurityGroupPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + securityGroupId := *securityGroup.Id + + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + // Map response body to schema + err = mapFields(ctx, securityGroup, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", 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, "Security group created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *securityGroupResource) 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 + } + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_id", securityGroupId) + + securityGroupResp, err := r.client.GetSecurityGroup(ctx, projectId, securityGroupId).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 security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, securityGroupResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", 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, "security group read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *securityGroupResource) 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 + } + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing security group + updatedSecurityGroup, err := r.client.UpdateSecurityGroup(ctx, projectId, securityGroupId).UpdateSecurityGroupPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, updatedSecurityGroup, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", 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, "security group updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *securityGroupResource) 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 + } + + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + // Delete existing security group + err := r.client.DeleteSecurityGroup(ctx, projectId, securityGroupId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "security group deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,security_group_id +func (r *securityGroupResource) 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 security group", + fmt.Sprintf("Expected import identifier with format: [project_id],[security_group_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + securityGroupId := idParts[1] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("security_group_id"), securityGroupId)...) + tflog.Info(ctx, "security group state imported") +} + +func mapFields(ctx context.Context, securityGroupResp *iaas.SecurityGroup, model *Model) error { + if securityGroupResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var securityGroupId string + if model.SecurityGroupId.ValueString() != "" { + securityGroupId = model.SecurityGroupId.ValueString() + } else if securityGroupResp.Id != nil { + securityGroupId = *securityGroupResp.Id + } else { + return fmt.Errorf("security group id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + securityGroupId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if securityGroupResp.Labels != nil && len(*securityGroupResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *securityGroupResp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + + model.SecurityGroupId = types.StringValue(securityGroupId) + model.Name = types.StringPointerValue(securityGroupResp.Name) + model.Description = types.StringPointerValue(securityGroupResp.Description) + model.Stateful = types.BoolPointerValue(securityGroupResp.Stateful) + model.Labels = labels + + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateSecurityGroupPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaas.CreateSecurityGroupPayload{ + Stateful: conversion.BoolValueToPointer(model.Stateful), + Description: conversion.StringValueToPointer(model.Description), + Labels: &labels, + Name: conversion.StringValueToPointer(model.Name), + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateSecurityGroupPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaas.UpdateSecurityGroupPayload{ + Description: conversion.StringValueToPointer(model.Description), + Name: conversion.StringValueToPointer(model.Name), + Labels: &labels, + }, nil +} diff --git a/stackit/internal/services/iaas/securitygroup/resource_test.go b/stackit/internal/services/iaas/securitygroup/resource_test.go new file mode 100644 index 00000000..2b4c1236 --- /dev/null +++ b/stackit/internal/services/iaas/securitygroup/resource_test.go @@ -0,0 +1,218 @@ +package securitygroup + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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.SecurityGroup + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + }, + &iaas.SecurityGroup{ + Id: utils.Ptr("sgid"), + }, + Model{ + Id: types.StringValue("pid,sgid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + Name: types.StringNull(), + Labels: types.MapNull(types.StringType), + Description: types.StringNull(), + Stateful: types.BoolNull(), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + }, + // &sourceModel{}, + &iaas.SecurityGroup{ + Id: utils.Ptr("sgid"), + Name: utils.Ptr("name"), + Stateful: utils.Ptr(true), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + }, + Model{ + Id: types.StringValue("pid,sgid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + Name: types.StringValue("name"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + Stateful: types.BoolValue(true), + }, + true, + }, + { + "empty_labels", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + }, + &iaas.SecurityGroup{ + Id: utils.Ptr("sgid"), + Labels: &map[string]interface{}{}, + }, + Model{ + Id: types.StringValue("pid,sgid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + Name: types.StringNull(), + Labels: types.MapNull(types.StringType), + Description: types.StringNull(), + Stateful: types.BoolNull(), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaas.SecurityGroup{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), 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.CreateSecurityGroupPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + Stateful: types.BoolValue(true), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + }, + &iaas.CreateSecurityGroupPayload{ + Name: utils.Ptr("name"), + Stateful: utils.Ptr(true), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + }, + 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.UpdateSecurityGroupPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + }, + &iaas.UpdateSecurityGroupPayload{ + Name: utils.Ptr("name"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + 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/services/iaas/securitygrouprule/datasource.go b/stackit/internal/services/iaas/securitygrouprule/datasource.go new file mode 100644 index 00000000..47bf50b6 --- /dev/null +++ b/stackit/internal/services/iaas/securitygrouprule/datasource.go @@ -0,0 +1,228 @@ +package securitygrouprule + +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/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// securityGroupRuleDataSourceBetaCheckDone 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 securityGroupRuleDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &securityGroupRuleDataSource{} +) + +// NewSecurityGroupRuleDataSource is a helper function to simplify the provider implementation. +func NewSecurityGroupRuleDataSource() datasource.DataSource { + return &securityGroupRuleDataSource{} +} + +// securityGroupRuleDataSource is the data source implementation. +type securityGroupRuleDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *securityGroupRuleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_security_group_rule" +} + +func (d *securityGroupRuleDataSource) 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 !securityGroupRuleDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_security_group_rule", "data source") + if resp.Diagnostics.HasError() { + return + } + securityGroupRuleDataSourceBetaCheckDone = 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 resource. +func (r *securityGroupRuleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + directionOptions := []string{"ingress", "egress"} + + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Security group datasource schema. Must have a `region` specified in the provider configuration."), + Description: "Security group datasource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal datasource ID. It is structured as \"`project_id`,`security_group_id`,`security_group_rule_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the security group rule is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "security_group_id": schema.StringAttribute{ + Description: "The security group ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "security_group_rule_id": schema.StringAttribute{ + Description: "The security group rule ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "direction": schema.StringAttribute{ + Description: "The direction of the traffic which the rule should match. Some of the possible values are: " + utils.SupportedValuesDocumentation(directionOptions), + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "The description of the security group rule.", + Computed: true, + }, + "ether_type": schema.StringAttribute{ + Description: "The ethertype which the rule should match.", + Computed: true, + }, + "icmp_parameters": schema.SingleNestedAttribute{ + Description: "ICMP Parameters.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "code": schema.Int64Attribute{ + Description: "ICMP code. Can be set if the protocol is ICMP.", + Computed: true, + }, + "type": schema.Int64Attribute{ + Description: "ICMP type. Can be set if the protocol is ICMP.", + Computed: true, + }, + }, + }, + "ip_range": schema.StringAttribute{ + Description: "The remote IP range which the rule should match.", + Computed: true, + }, + "port_range": schema.SingleNestedAttribute{ + Description: "The range of ports.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "max": schema.Int64Attribute{ + Description: "The maximum port number. Should be greater or equal to the minimum.", + Computed: true, + }, + "min": schema.Int64Attribute{ + Description: "The minimum port number. Should be less or equal to the minimum.", + Computed: true, + }, + }, + }, + "protocol": schema.SingleNestedAttribute{ + Description: "The internet protocol which the rule should match.", + Computed: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The protocol name which the rule should match.", + Computed: true, + }, + "number": schema.Int64Attribute{ + Description: "The protocol number which the rule should match.", + Computed: true, + }, + }, + }, + "remote_security_group_id": schema.StringAttribute{ + Description: "The remote security group which the rule should match.", + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *securityGroupRuleDataSource) 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 + } + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + securityGroupRuleId := model.SecurityGroupRuleId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId) + + securityGroupRuleResp, err := d.client.GetSecurityGroupRule(ctx, projectId, securityGroupId, securityGroupRuleId).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 security group rule", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(securityGroupRuleResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group rule", 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, "security group rule read") +} diff --git a/stackit/internal/services/iaas/securitygrouprule/planmodifier.go b/stackit/internal/services/iaas/securitygrouprule/planmodifier.go new file mode 100644 index 00000000..23d879f9 --- /dev/null +++ b/stackit/internal/services/iaas/securitygrouprule/planmodifier.go @@ -0,0 +1,93 @@ +package securitygrouprule + +import ( + "context" + "slices" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" +) + +// UseNullForUnknownBasedOnProtocolModifier returns a plan modifier that sets a null +// value into the planned value, based on the value of the protocol.name attribute. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// To prevent always showing "(known after apply)" on update for an attribute, e.g. port_range, which never changes in case the protocol is a specific one, +// we set the value to null. +// Examples: port_range is only computed if protocol is not icmp and icmp_parameters is only computed if protocol is icmp +func UseNullForUnknownBasedOnProtocolModifier() planmodifier.Object { + return useNullForUnknownBasedOnProtocolModifier{} +} + +// useNullForUnknownBasedOnProtocolModifier implements the plan modifier. +type useNullForUnknownBasedOnProtocolModifier struct{} + +func (m useNullForUnknownBasedOnProtocolModifier) Description(_ context.Context) string { + return "If protocol.name attribute is set and the value corresponds to an icmp protocol, the value of this attribute in state will be set to null." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useNullForUnknownBasedOnProtocolModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will be set to null if protocol.name attribute is set and the value corresponds to an icmp protocol." +} + +// PlanModifyBool implements the plan modification logic. +func (m useNullForUnknownBasedOnProtocolModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { // nolint:gocritic // function signature required by Terraform + // Check if the resource is being created. + if req.State.Raw.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + // If there is an unknown configuration value, check if the value of protocol.name attribute corresponds to an icmp protocol. If it does, set the attribute value to null + var model Model + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + // If protocol is not configured, return without error. + if model.Protocol.IsNull() || model.Protocol.IsUnknown() { + return + } + + protocol := &protocolModel{} + diags := model.Protocol.As(ctx, protocol, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + protocolName := conversion.StringValueToPointer(protocol.Name) + + if protocolName == nil { + return + } + + if slices.Contains(icmpProtocols, *protocolName) { + if model.PortRange.IsUnknown() { + resp.PlanValue = types.ObjectNull(portRangeTypes) + return + } + } else { + if model.IcmpParameters.IsUnknown() { + resp.PlanValue = types.ObjectNull(icmpParametersTypes) + return + } + } + + // use state for unknown if the value was not set to null + resp.PlanValue = req.StateValue +} diff --git a/stackit/internal/services/iaas/securitygrouprule/resource.go b/stackit/internal/services/iaas/securitygrouprule/resource.go new file mode 100644 index 00000000..4edd4ae1 --- /dev/null +++ b/stackit/internal/services/iaas/securitygrouprule/resource.go @@ -0,0 +1,777 @@ +package securitygrouprule + +import ( + "context" + "fmt" + "net/http" + "slices" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "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-framework/types/basetypes" + "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/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 = &securityGroupRuleResource{} + _ resource.ResourceWithConfigure = &securityGroupRuleResource{} + _ resource.ResourceWithImportState = &securityGroupRuleResource{} + icmpProtocols = []string{"icmp", "ipv6-icmp"} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + SecurityGroupId types.String `tfsdk:"security_group_id"` + SecurityGroupRuleId types.String `tfsdk:"security_group_rule_id"` + Direction types.String `tfsdk:"direction"` + Description types.String `tfsdk:"description"` + EtherType types.String `tfsdk:"ether_type"` + IcmpParameters types.Object `tfsdk:"icmp_parameters"` + IpRange types.String `tfsdk:"ip_range"` + PortRange types.Object `tfsdk:"port_range"` + Protocol types.Object `tfsdk:"protocol"` + RemoteSecurityGroupId types.String `tfsdk:"remote_security_group_id"` +} + +type icmpParametersModel struct { + Code types.Int64 `tfsdk:"code"` + Type types.Int64 `tfsdk:"type"` +} + +// Types corresponding to icmpParameters +var icmpParametersTypes = map[string]attr.Type{ + "code": basetypes.Int64Type{}, + "type": basetypes.Int64Type{}, +} + +type portRangeModel struct { + Max types.Int64 `tfsdk:"max"` + Min types.Int64 `tfsdk:"min"` +} + +// Types corresponding to portRange +var portRangeTypes = map[string]attr.Type{ + "max": basetypes.Int64Type{}, + "min": basetypes.Int64Type{}, +} + +type protocolModel struct { + Name types.String `tfsdk:"name"` + Number types.Int64 `tfsdk:"number"` +} + +// Types corresponding to protocol +var protocolTypes = map[string]attr.Type{ + "name": basetypes.StringType{}, + "number": basetypes.Int64Type{}, +} + +// NewSecurityGroupRuleResource is a helper function to simplify the provider implementation. +func NewSecurityGroupRuleResource() resource.Resource { + return &securityGroupRuleResource{} +} + +// securityGroupRuleResource is the resource implementation. +type securityGroupRuleResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *securityGroupRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_security_group_rule" +} + +// Configure adds the provider configured client to the resource. +func (r *securityGroupRuleResource) 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_security_group_rule", "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") +} + +func (r securityGroupRuleResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var model Model + + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + + if resp.Diagnostics.HasError() { + return + } + + // If protocol is not configured, return without error. + if model.Protocol.IsNull() || model.Protocol.IsUnknown() { + return + } + + protocol := &protocolModel{} + diags := model.Protocol.As(ctx, protocol, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + protocolName := conversion.StringValueToPointer(protocol.Name) + + if protocolName == nil { + return + } + + if slices.Contains(icmpProtocols, *protocolName) { + if !(model.PortRange.IsNull() || model.PortRange.IsUnknown()) { + resp.Diagnostics.AddAttributeError( + path.Root("port_range"), + "Conflicting attribute configuration", + "`port_range` attribute can't be provided if `protocol.name` is set to `icmp` or `ipv6-icmp`", + ) + } + } else { + if !(model.IcmpParameters.IsNull() || model.IcmpParameters.IsUnknown()) { + resp.Diagnostics.AddAttributeError( + path.Root("icmp_parameters"), + "Conflicting attribute configuration", + "`icmp_parameters` attribute can't be provided if `protocol.name` is not `icmp` or `ipv6-icmp`", + ) + } + } +} + +// Schema defines the schema for the resource. +func (r *securityGroupRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + directionOptions := []string{"ingress", "egress"} + + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Security group rule resource schema. Must have a `region` specified in the provider configuration."), + Description: "Security group rule 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 \"`project_id`,`security_group_id`,`security_group_rule_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the security group rule is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "security_group_id": schema.StringAttribute{ + Description: "The security group ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "security_group_rule_id": schema.StringAttribute{ + Description: "The security group rule ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "description": schema.StringAttribute{ + Description: "The rule description.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplaceIfConfigured(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtMost(127), + }, + }, + "direction": schema.StringAttribute{ + Description: "The direction of the traffic which the rule should match. Some of the possible values are: " + utils.SupportedValuesDocumentation(directionOptions), + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "ether_type": schema.StringAttribute{ + Description: "The ethertype which the rule should match.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplaceIfConfigured(), + }, + }, + "icmp_parameters": schema.SingleNestedAttribute{ + Description: "ICMP Parameters. These parameters should only be provided if the protocol is ICMP.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + UseNullForUnknownBasedOnProtocolModifier(), + objectplanmodifier.RequiresReplaceIfConfigured(), + }, + Attributes: map[string]schema.Attribute{ + "code": schema.Int64Attribute{ + Description: "ICMP code. Can be set if the protocol is ICMP.", + Required: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + int64validator.AtMost(255), + }, + }, + "type": schema.Int64Attribute{ + Description: "ICMP type. Can be set if the protocol is ICMP.", + Required: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + int64validator.AtMost(255), + }, + }, + }, + }, + "ip_range": schema.StringAttribute{ + Description: "The remote IP range which the rule should match.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.IP(), + }, + }, + "port_range": schema.SingleNestedAttribute{ + Description: "The range of ports. This should only be provided if the protocol is not ICMP.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplaceIfConfigured(), + UseNullForUnknownBasedOnProtocolModifier(), + }, + Attributes: map[string]schema.Attribute{ + "max": schema.Int64Attribute{ + Description: "The maximum port number. Should be greater or equal to the minimum.", + Required: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + int64validator.AtMost(65535), + }, + }, + "min": schema.Int64Attribute{ + Description: "The minimum port number. Should be less or equal to the maximum.", + Required: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + int64validator.AtMost(65535), + }, + }, + }, + }, + "protocol": schema.SingleNestedAttribute{ + Description: "The internet protocol which the rule should match.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplaceIfConfigured(), + objectplanmodifier.UseStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "The protocol name which the rule should match. Either `name` or `number` must be provided.", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.AtLeastOneOf( + path.MatchRoot("protocol").AtName("number"), + ), + stringvalidator.ConflictsWith( + path.MatchRoot("protocol").AtName("number"), + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplaceIfConfigured(), + }, + }, + "number": schema.Int64Attribute{ + Description: "The protocol number which the rule should match. Either `name` or `number` must be provided.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + int64planmodifier.RequiresReplaceIfConfigured(), + }, + Validators: []validator.Int64{ + int64validator.AtLeast(0), + int64validator.AtMost(255), + }, + }, + }, + }, + "remote_security_group_id": schema.StringAttribute{ + Description: "The remote security group which the rule should match.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *securityGroupRuleResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + securityGroupId := model.SecurityGroupId.ValueString() + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + var icmpParameters *icmpParametersModel + if !(model.IcmpParameters.IsNull() || model.IcmpParameters.IsUnknown()) { + icmpParameters = &icmpParametersModel{} + diags = model.IcmpParameters.As(ctx, icmpParameters, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + var portRange *portRangeModel + if !(model.PortRange.IsNull() || model.PortRange.IsUnknown()) { + portRange = &portRangeModel{} + diags = model.PortRange.As(ctx, portRange, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + var protocol *protocolModel + if !(model.Protocol.IsNull() || model.Protocol.IsUnknown()) { + protocol = &protocolModel{} + diags = model.Protocol.As(ctx, protocol, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + // Generate API request body from model + payload, err := toCreatePayload(&model, icmpParameters, portRange, protocol) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group rule", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new security group rule + securityGroupRule, err := r.client.CreateSecurityGroupRule(ctx, projectId, securityGroupId).CreateSecurityGroupRulePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group rule", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = tflog.SetField(ctx, "security_group_rule_id", *securityGroupRule.Id) + + // Map response body to schema + err = mapFields(securityGroupRule, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group rule", 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, "Security group rule created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *securityGroupRuleResource) 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 + } + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + securityGroupRuleId := model.SecurityGroupRuleId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId) + + securityGroupRuleResp, err := r.client.GetSecurityGroupRule(ctx, projectId, securityGroupId, securityGroupRuleId).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 security group rule", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(securityGroupRuleResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group rule", 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, "security group rule read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *securityGroupRuleResource) 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 security group rule", "Security group rule can't be updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *securityGroupRuleResource) 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 + } + + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + securityGroupRuleId := model.SecurityGroupRuleId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId) + + // Delete existing security group rule + err := r.client.DeleteSecurityGroupRule(ctx, projectId, securityGroupId, securityGroupRuleId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting security group rule", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "security group rule deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,security_group_id, security_group_rule_id +func (r *securityGroupRuleResource) 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 security group rule", + fmt.Sprintf("Expected import identifier with format: [project_id],[security_group_id],[security_group_rule_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + securityGroupId := idParts[1] + securityGroupRuleId := idParts[2] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("security_group_id"), securityGroupId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("security_group_rule_id"), securityGroupRuleId)...) + tflog.Info(ctx, "security group rule state imported") +} + +func mapFields(securityGroupRuleResp *iaas.SecurityGroupRule, model *Model) error { + if securityGroupRuleResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var securityGroupRuleId string + if model.SecurityGroupRuleId.ValueString() != "" { + securityGroupRuleId = model.SecurityGroupRuleId.ValueString() + } else if securityGroupRuleResp.Id != nil { + securityGroupRuleId = *securityGroupRuleResp.Id + } else { + return fmt.Errorf("security group rule id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + model.SecurityGroupId.ValueString(), + securityGroupRuleId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + model.SecurityGroupRuleId = types.StringValue(securityGroupRuleId) + model.Direction = types.StringPointerValue(securityGroupRuleResp.Direction) + model.Description = types.StringPointerValue(securityGroupRuleResp.Description) + model.EtherType = types.StringPointerValue(securityGroupRuleResp.Ethertype) + model.IpRange = types.StringPointerValue(securityGroupRuleResp.IpRange) + model.RemoteSecurityGroupId = types.StringPointerValue(securityGroupRuleResp.RemoteSecurityGroupId) + + err := mapIcmpParameters(securityGroupRuleResp, model) + if err != nil { + return fmt.Errorf("map icmp_parameters: %w", err) + } + err = mapPortRange(securityGroupRuleResp, model) + if err != nil { + return fmt.Errorf("map port_range: %w", err) + } + err = mapProtocol(securityGroupRuleResp, model) + if err != nil { + return fmt.Errorf("map protocol: %w", err) + } + + return nil +} + +func mapIcmpParameters(securityGroupRuleResp *iaas.SecurityGroupRule, m *Model) error { + if securityGroupRuleResp.IcmpParameters == nil { + m.IcmpParameters = types.ObjectNull(icmpParametersTypes) + return nil + } + + icmpParametersValues := map[string]attr.Value{ + "type": types.Int64Value(*securityGroupRuleResp.IcmpParameters.Type), + "code": types.Int64Value(*securityGroupRuleResp.IcmpParameters.Code), + } + + icmpParametersObject, diags := types.ObjectValue(icmpParametersTypes, icmpParametersValues) + if diags.HasError() { + return fmt.Errorf("create icmpParameters object: %w", core.DiagsToError(diags)) + } + m.IcmpParameters = icmpParametersObject + return nil +} + +func mapPortRange(securityGroupRuleResp *iaas.SecurityGroupRule, m *Model) error { + if securityGroupRuleResp.PortRange == nil { + m.PortRange = types.ObjectNull(portRangeTypes) + return nil + } + + portRangeMax := types.Int64Null() + portRangeMin := types.Int64Null() + + if securityGroupRuleResp.PortRange.Max != nil { + portRangeMax = types.Int64Value(*securityGroupRuleResp.PortRange.Max) + } + + if securityGroupRuleResp.PortRange.Min != nil { + portRangeMin = types.Int64Value(*securityGroupRuleResp.PortRange.Min) + } + + portRangeValues := map[string]attr.Value{ + "max": portRangeMax, + "min": portRangeMin, + } + + portRangeObject, diags := types.ObjectValue(portRangeTypes, portRangeValues) + if diags.HasError() { + return fmt.Errorf("create portRange object: %w", core.DiagsToError(diags)) + } + m.PortRange = portRangeObject + return nil +} + +func mapProtocol(securityGroupRuleResp *iaas.SecurityGroupRule, m *Model) error { + if securityGroupRuleResp.Protocol == nil { + m.Protocol = types.ObjectNull(protocolTypes) + return nil + } + + protocolNumberValue := types.Int64Null() + if securityGroupRuleResp.Protocol.Number != nil { + protocolNumberValue = types.Int64Value(*securityGroupRuleResp.Protocol.Number) + } + + protocolNameValue := types.StringNull() + if securityGroupRuleResp.Protocol.Name != nil { + protocolNameValue = types.StringValue(*securityGroupRuleResp.Protocol.Name) + } + + protocolValues := map[string]attr.Value{ + "name": protocolNameValue, + "number": protocolNumberValue, + } + protocolObject, diags := types.ObjectValue(protocolTypes, protocolValues) + if diags.HasError() { + return fmt.Errorf("create protocol object: %w", core.DiagsToError(diags)) + } + m.Protocol = protocolObject + return nil +} + +func toCreatePayload(model *Model, icmpParameters *icmpParametersModel, portRange *portRangeModel, protocol *protocolModel) (*iaas.CreateSecurityGroupRulePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + payloadIcmpParameters, err := toIcmpParametersPayload(icmpParameters) + if err != nil { + return nil, fmt.Errorf("converting icmp parameters: %w", err) + } + + payloadPortRange, err := toPortRangePayload(portRange) + if err != nil { + return nil, fmt.Errorf("converting port range: %w", err) + } + + payloadProtocol, err := toProtocolPayload(protocol) + if err != nil { + return nil, fmt.Errorf("converting protocol: %w", err) + } + + return &iaas.CreateSecurityGroupRulePayload{ + Description: conversion.StringValueToPointer(model.Description), + Direction: conversion.StringValueToPointer(model.Direction), + Ethertype: conversion.StringValueToPointer(model.EtherType), + IpRange: conversion.StringValueToPointer(model.IpRange), + RemoteSecurityGroupId: conversion.StringValueToPointer(model.RemoteSecurityGroupId), + IcmpParameters: payloadIcmpParameters, + PortRange: payloadPortRange, + Protocol: payloadProtocol, + }, nil +} + +func toIcmpParametersPayload(icmpParameters *icmpParametersModel) (*iaas.ICMPParameters, error) { + if icmpParameters == nil { + return nil, nil + } + payloadParams := &iaas.ICMPParameters{} + + payloadParams.Code = conversion.Int64ValueToPointer(icmpParameters.Code) + payloadParams.Type = conversion.Int64ValueToPointer(icmpParameters.Type) + + return payloadParams, nil +} + +func toPortRangePayload(portRange *portRangeModel) (*iaas.PortRange, error) { + if portRange == nil { + return nil, nil + } + payloadPortRange := &iaas.PortRange{} + + payloadPortRange.Max = conversion.Int64ValueToPointer(portRange.Max) + payloadPortRange.Min = conversion.Int64ValueToPointer(portRange.Min) + + return payloadPortRange, nil +} + +func toProtocolPayload(protocol *protocolModel) (*iaas.CreateProtocol, error) { + if protocol == nil { + return nil, nil + } + payloadProtocol := &iaas.CreateProtocol{} + + payloadProtocol.String = conversion.StringValueToPointer(protocol.Name) + payloadProtocol.Int64 = conversion.Int64ValueToPointer(protocol.Number) + + return payloadProtocol, nil +} diff --git a/stackit/internal/services/iaas/securitygrouprule/resource_test.go b/stackit/internal/services/iaas/securitygrouprule/resource_test.go new file mode 100644 index 00000000..ef6dc006 --- /dev/null +++ b/stackit/internal/services/iaas/securitygrouprule/resource_test.go @@ -0,0 +1,305 @@ +package securitygrouprule + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +var fixtureModelIcmpParameters = types.ObjectValueMust(icmpParametersTypes, map[string]attr.Value{ + "code": types.Int64Value(1), + "type": types.Int64Value(2), +}) + +var fixtureIcmpParameters = iaas.ICMPParameters{ + Code: utils.Ptr(int64(1)), + Type: utils.Ptr(int64(2)), +} + +var fixtureModelPortRange = types.ObjectValueMust(portRangeTypes, map[string]attr.Value{ + "max": types.Int64Value(2), + "min": types.Int64Value(1), +}) + +var fixturePortRange = iaas.PortRange{ + Max: utils.Ptr(int64(2)), + Min: utils.Ptr(int64(1)), +} + +var fixtureModelProtocol = types.ObjectValueMust(protocolTypes, map[string]attr.Value{ + "name": types.StringValue("name"), + "number": types.Int64Value(1), +}) + +var fixtureProtocol = iaas.Protocol{ + Name: utils.Ptr("name"), + Number: utils.Ptr(int64(1)), +} + +var fixtureModelCreateProtocol = types.ObjectValueMust(protocolTypes, map[string]attr.Value{ + "name": types.StringValue("name"), + "number": types.Int64Null(), +}) + +var fixtureCreateProtocol = iaas.CreateProtocol{ + String: utils.Ptr("name"), +} + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaas.SecurityGroupRule + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + SecurityGroupRuleId: types.StringValue("sgrid"), + }, + &iaas.SecurityGroupRule{ + Id: utils.Ptr("sgrid"), + }, + Model{ + Id: types.StringValue("pid,sgid,sgrid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + SecurityGroupRuleId: types.StringValue("sgrid"), + Direction: types.StringNull(), + Description: types.StringNull(), + EtherType: types.StringNull(), + IpRange: types.StringNull(), + RemoteSecurityGroupId: types.StringNull(), + IcmpParameters: types.ObjectNull(icmpParametersTypes), + PortRange: types.ObjectNull(portRangeTypes), + Protocol: types.ObjectNull(protocolTypes), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + SecurityGroupRuleId: types.StringValue("sgrid"), + }, + &iaas.SecurityGroupRule{ + Id: utils.Ptr("sgrid"), + Description: utils.Ptr("desc"), + Direction: utils.Ptr("ingress"), + Ethertype: utils.Ptr("ether"), + IpRange: utils.Ptr("iprange"), + RemoteSecurityGroupId: utils.Ptr("remote"), + IcmpParameters: &fixtureIcmpParameters, + PortRange: &fixturePortRange, + Protocol: &fixtureProtocol, + }, + Model{ + Id: types.StringValue("pid,sgid,sgrid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + SecurityGroupRuleId: types.StringValue("sgrid"), + Direction: types.StringValue("ingress"), + Description: types.StringValue("desc"), + EtherType: types.StringValue("ether"), + IpRange: types.StringValue("iprange"), + RemoteSecurityGroupId: types.StringValue("remote"), + IcmpParameters: fixtureModelIcmpParameters, + PortRange: fixtureModelPortRange, + Protocol: fixtureModelProtocol, + }, + true, + }, + { + "protocol_only_with_name", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + SecurityGroupRuleId: types.StringValue("sgrid"), + Protocol: types.ObjectValueMust(protocolTypes, map[string]attr.Value{ + "name": types.StringValue("name"), + "number": types.Int64Null(), + }), + }, + &iaas.SecurityGroupRule{ + Id: utils.Ptr("sgrid"), + Protocol: &fixtureProtocol, + }, + Model{ + Id: types.StringValue("pid,sgid,sgrid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + SecurityGroupRuleId: types.StringValue("sgrid"), + Direction: types.StringNull(), + Description: types.StringNull(), + EtherType: types.StringNull(), + IpRange: types.StringNull(), + RemoteSecurityGroupId: types.StringNull(), + IcmpParameters: types.ObjectNull(icmpParametersTypes), + PortRange: types.ObjectNull(portRangeTypes), + Protocol: fixtureModelProtocol, + }, + true, + }, + { + "protocol_only_with_number", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + SecurityGroupRuleId: types.StringValue("sgrid"), + Protocol: types.ObjectValueMust(protocolTypes, map[string]attr.Value{ + "name": types.StringNull(), + "number": types.Int64Value(1), + }), + }, + &iaas.SecurityGroupRule{ + Id: utils.Ptr("sgrid"), + Protocol: &fixtureProtocol, + }, + Model{ + Id: types.StringValue("pid,sgid,sgrid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + SecurityGroupRuleId: types.StringValue("sgrid"), + Direction: types.StringNull(), + Description: types.StringNull(), + EtherType: types.StringNull(), + IpRange: types.StringNull(), + RemoteSecurityGroupId: types.StringNull(), + IcmpParameters: types.ObjectNull(icmpParametersTypes), + PortRange: types.ObjectNull(portRangeTypes), + Protocol: fixtureModelProtocol, + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + }, + &iaas.SecurityGroupRule{}, + 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.CreateSecurityGroupRulePayload + isValid bool + }{ + { + "default_values", + &Model{}, + &iaas.CreateSecurityGroupRulePayload{}, + true, + }, + { + "default_ok", + &Model{ + Description: types.StringValue("desc"), + Direction: types.StringValue("ingress"), + IcmpParameters: fixtureModelIcmpParameters, + PortRange: fixtureModelPortRange, + Protocol: fixtureModelCreateProtocol, + }, + &iaas.CreateSecurityGroupRulePayload{ + Description: utils.Ptr("desc"), + Direction: utils.Ptr("ingress"), + IcmpParameters: &fixtureIcmpParameters, + PortRange: &fixturePortRange, + Protocol: &fixtureCreateProtocol, + }, + true, + }, + { + "nil_model", + nil, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var icmpParameters *icmpParametersModel + var portRange *portRangeModel + var protocol *protocolModel + if tt.input != nil { + if !(tt.input.IcmpParameters.IsNull() || tt.input.IcmpParameters.IsUnknown()) { + icmpParameters = &icmpParametersModel{} + diags := tt.input.IcmpParameters.As(context.Background(), icmpParameters, basetypes.ObjectAsOptions{}) + if diags.HasError() { + t.Fatalf("Error converting icmp parameters: %v", diags.Errors()) + } + } + + if !(tt.input.PortRange.IsNull() || tt.input.PortRange.IsUnknown()) { + portRange = &portRangeModel{} + diags := tt.input.PortRange.As(context.Background(), portRange, basetypes.ObjectAsOptions{}) + if diags.HasError() { + t.Fatalf("Error converting port range: %v", diags.Errors()) + } + } + + if !(tt.input.Protocol.IsNull() || tt.input.Protocol.IsUnknown()) { + protocol = &protocolModel{} + diags := tt.input.Protocol.As(context.Background(), protocol, basetypes.ObjectAsOptions{}) + if diags.HasError() { + t.Fatalf("Error converting protocol: %v", diags.Errors()) + } + } + } + + output, err := toCreatePayload(tt.input, icmpParameters, portRange, protocol) + 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/services/iaas/server/const.go b/stackit/internal/services/iaas/server/const.go new file mode 100644 index 00000000..ce010ddf --- /dev/null +++ b/stackit/internal/services/iaas/server/const.go @@ -0,0 +1,167 @@ +package server + +const markdownDescription = ` +Server resource schema. Must have a region specified in the provider configuration.` + "\n" + ` +~> 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` + "\n" + ` + +### Boot from volume` + "\n" + + + "```terraform" + ` +resource "stackit_server" "boot-from-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + availability_zone = "eu01-1" + machine_type = "g1.1" + keypair_name = "example-keypair" +} +` + "\n```" + ` + +### Boot from existing volume` + "\n" + + + "```terraform" + ` +resource "stackit_volume" "example-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + size = 12 + source = { + type = "image" + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-volume" + availability_zone = "eu01-1" +} + +resource "stackit_server" "boot-from-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + source_type = "volume" + source_id = stackit_volume.example-volume.volume_id + } + availability_zone = "eu01-1" + machine_type = "g1.1" + keypair_name = "example-keypair" +} +` + "\n```" + ` + +### Network setup` + "\n" + + + "```terraform" + ` +resource "stackit_server" "server-with-network" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + machine_type = "g1.1" + keypair_name = "example-keypair" +} + +resource "stackit_network" "network" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-network" + nameservers = ["192.0.2.0", "198.51.100.0", "203.0.113.0"] + ipv4_prefix_length = 24 +} + +resource "stackit_security_group" "sec-group" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-security-group" + stateful = true +} + +resource "stackit_security_group_rule" "rule" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = stackit_security_group.sec-group.security_group_id + direction = "ingress" + ether_type = "IPv4" +} + +resource "stackit_network_interface" "nic" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = stackit_network.network.network_id + security_group_ids = [stackit_security_group.sec-group.security_group_id] +} + +resource "stackit_public_ip" "public-ip" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = stackit_network_interface.nic.network_interface_id +} + +resource "stackit_server_network_interface_attach" "nic-attachment" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = stackit_server.server-with-network.server_id + network_interface_id = stackit_network_interface.nic.network_interface_id +} +` + "\n```" + ` + +### Server with attached volume` + "\n" + + + "```terraform" + ` +resource "stackit_volume" "example-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + size = 12 + source = { + type = "image" + id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-volume" + availability_zone = "eu01-1" +} + +resource "stackit_server" "server-with-volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + availability_zone = "eu01-1" + machine_type = "g1.1" + keypair_name = "example-keypair" +} + +resource "stackit_server_volume_attach" "attach_volume" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + server_id = stackit_server.server-with-volume.server_id + volume_id = stackit_volume.example-volume.volume_id +} +` + "\n```" + ` + +### Server with user data (cloud-init)` + "\n" + + + "```terraform" + ` +resource "stackit_server" "user-data" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-server" + machine_type = "g1.1" + keypair_name = "example-keypair" + user_data = "#!/bin/bash\n/bin/su" +} + +resource "stackit_server" "user-data-from-file" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + boot_volume = { + size = 64 + source_type = "image" + source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-server" + machine_type = "g1.1" + keypair_name = "example-keypair" + user_data = file("${path.module}/cloud-init.yaml") +} +` + "\n```" diff --git a/stackit/internal/services/iaas/server/datasource.go b/stackit/internal/services/iaas/server/datasource.go new file mode 100644 index 00000000..78c84a42 --- /dev/null +++ b/stackit/internal/services/iaas/server/datasource.go @@ -0,0 +1,224 @@ +package server + +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-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/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// serverDataSourceBetaCheckDone 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 serverDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &serverDataSource{} +) + +// NewServerDataSource is a helper function to simplify the provider implementation. +func NewServerDataSource() datasource.DataSource { + return &serverDataSource{} +} + +// serverDataSource is the data source implementation. +type serverDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *serverDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_server" +} + +func (d *serverDataSource) 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 !serverDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_server", "data source") + if resp.Diagnostics.HasError() { + return + } + serverDataSourceBetaCheckDone = 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 datasource. +func (r *serverDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Server datasource schema. Must have a `region` specified in the provider configuration."), + Description: "Server 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 \"`project_id`,`server_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the server is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the server.", + Computed: true, + }, + "machine_type": schema.StringAttribute{ + MarkdownDescription: "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)", + Computed: true, + }, + "availability_zone": schema.StringAttribute{ + Description: "The availability zone of the server.", + Computed: true, + }, + "boot_volume": schema.SingleNestedAttribute{ + Description: "The boot volume for the server", + Computed: true, + Attributes: map[string]schema.Attribute{ + "performance_class": schema.StringAttribute{ + Description: "The performance class of the server.", + Computed: true, + }, + "size": schema.Int64Attribute{ + Description: "The size of the boot volume in GB.", + Computed: true, + }, + "type": schema.StringAttribute{ + Description: "The type of the source. " + utils.SupportedValuesDocumentation(supportedSourceTypes), + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The ID of the source, either image ID or volume ID", + Computed: true, + }, + }, + }, + "image_id": schema.StringAttribute{ + Description: "The image ID to be used for an ephemeral disk on the server.", + Computed: true, + }, + "keypair_name": schema.StringAttribute{ + Description: "The name of the keypair used during server creation.", + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, + "affinity_group": schema.StringAttribute{ + Description: "The affinity group the server is assigned to.", + Computed: true, + }, + "user_data": schema.StringAttribute{ + Description: "User data that is passed via cloud-init to the server.", + Computed: true, + }, + "created_at": schema.StringAttribute{ + Description: "Date-time when the server was created", + Computed: true, + }, + "launched_at": schema.StringAttribute{ + Description: "Date-time when the server was launched", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + Description: "Date-time when the server was updated", + Computed: true, + }, + }, + } +} + +// // Read refreshes the Terraform state with the latest data. +func (r *serverDataSource) 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 + } + projectId := model.ProjectId.ValueString() + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + serverResp, err := r.client.GetServer(ctx, projectId, serverId).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 server", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, serverResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", 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, "server read") +} diff --git a/stackit/internal/services/iaas/server/resource.go b/stackit/internal/services/iaas/server/resource.go new file mode 100644 index 00000000..99a8a897 --- /dev/null +++ b/stackit/internal/services/iaas/server/resource.go @@ -0,0 +1,682 @@ +package server + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "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/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "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-framework/types/basetypes" + "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/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" + "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 = &serverResource{} + _ resource.ResourceWithConfigure = &serverResource{} + _ resource.ResourceWithImportState = &serverResource{} + + supportedSourceTypes = []string{"volume", "image"} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + ServerId types.String `tfsdk:"server_id"` + MachineType types.String `tfsdk:"machine_type"` + Name types.String `tfsdk:"name"` + AvailabilityZone types.String `tfsdk:"availability_zone"` + BootVolume types.Object `tfsdk:"boot_volume"` + ImageId types.String `tfsdk:"image_id"` + KeypairName types.String `tfsdk:"keypair_name"` + Labels types.Map `tfsdk:"labels"` + AffinityGroup types.String `tfsdk:"affinity_group"` + UserData types.String `tfsdk:"user_data"` + CreatedAt types.String `tfsdk:"created_at"` + LaunchedAt types.String `tfsdk:"launched_at"` + UpdatedAt types.String `tfsdk:"updated_at"` +} + +// Struct corresponding to Model.BootVolume +type bootVolumeModel struct { + PerformanceClass types.String `tfsdk:"performance_class"` + Size types.Int64 `tfsdk:"size"` + SourceType types.String `tfsdk:"source_type"` + SourceId types.String `tfsdk:"source_id"` +} + +// Types corresponding to bootVolumeModel +var bootVolumeTypes = map[string]attr.Type{ + "performance_class": basetypes.StringType{}, + "size": basetypes.Int64Type{}, + "source_type": basetypes.StringType{}, + "source_id": basetypes.StringType{}, +} + +// NewServerResource is a helper function to simplify the provider implementation. +func NewServerResource() resource.Resource { + return &serverResource{} +} + +// serverResource is the resource implementation. +type serverResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *serverResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_server" +} + +// ConfigValidators validates the resource configuration +func (r *serverResource) ConfigValidators(_ context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + resourcevalidator.AtLeastOneOf( + path.MatchRoot("image_id"), + path.MatchRoot("boot_volume"), + ), + resourcevalidator.Conflicting( + path.MatchRoot("image_id"), + path.MatchRoot("boot_volume"), + ), + } +} + +// Configure adds the provider configured client to the resource. +func (r *serverResource) 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_server", "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 *serverResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: markdownDescription, + Description: "Server 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 \"`project_id`,`server_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the server is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the server.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "machine_type": schema.StringAttribute{ + MarkdownDescription: "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "availability_zone": schema.StringAttribute{ + Description: "The availability zone of the server.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Optional: true, + Computed: true, + }, + "boot_volume": schema.SingleNestedAttribute{ + Description: "The boot volume for the server", + Optional: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), + }, + Attributes: map[string]schema.Attribute{ + "performance_class": schema.StringAttribute{ + Description: "The performance class of the server.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "size": schema.Int64Attribute{ + Description: "The size of the boot volume in GB. Must be provided when `source_type` is `image`.", + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "source_type": schema.StringAttribute{ + Description: "The type of the source. " + utils.SupportedValuesDocumentation(supportedSourceTypes), + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "source_id": schema.StringAttribute{ + Description: "The ID of the source, either image ID or volume ID", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + }, + "image_id": schema.StringAttribute{ + Description: "The image ID to be used for an ephemeral disk on the server.", + Optional: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "keypair_name": schema.StringAttribute{ + Description: "The name of the keypair used during server creation.", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, + "affinity_group": schema.StringAttribute{ + Description: "The affinity group the server is assigned to.", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(36), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`), + "must match expression"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "user_data": schema.StringAttribute{ + Description: "User data that is passed via cloud-init to the server.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "created_at": schema.StringAttribute{ + Description: "Date-time when the server was created", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "launched_at": schema.StringAttribute{ + Description: "Date-time when the server was launched", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + Description: "Date-time when the server was updated", + Computed: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *serverResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new server + + server, err := r.client.CreateServer(ctx, projectId).CreateServerPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("Calling API: %v", err)) + return + } + + serverId := *server.Id + server, err = wait.CreateServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("server creation waiting: %v", err)) + return + } + + ctx = tflog.SetField(ctx, "server_id", serverId) + + // Map response body to schema + err = mapFields(ctx, server, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", 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, "Server created") +} + +// // Read refreshes the Terraform state with the latest data. +func (r *serverResource) 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 + } + projectId := model.ProjectId.ValueString() + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + serverResp, err := r.client.GetServer(ctx, projectId, serverId).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 server", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, serverResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", 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, "server read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *serverResource) 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 + } + projectId := model.ProjectId.ValueString() + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing server + updatedServer, err := r.client.UpdateServer(ctx, projectId, serverId).UpdateServerPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Update machine type + modelMachineType := conversion.StringValueToPointer(model.MachineType) + if modelMachineType != nil && updatedServer.MachineType != nil && *modelMachineType != *updatedServer.MachineType { + payload := iaas.ResizeServerPayload{ + MachineType: modelMachineType, + } + err := r.client.ResizeServer(ctx, projectId, serverId).ResizeServerPayload(payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Resizing the server, calling API: %v", err)) + } + + _, err = wait.ResizeServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("server resize waiting: %v", err)) + return + } + // Update server model because the API doesn't return a server object as response + updatedServer.MachineType = modelMachineType + } + + err = mapFields(ctx, updatedServer, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", 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, "server updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *serverResource) 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 + } + + projectId := model.ProjectId.ValueString() + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + // Delete existing server + err := r.client.DeleteServer(ctx, projectId, serverId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting server", fmt.Sprintf("Calling API: %v", err)) + return + } + _, err = wait.DeleteServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting server", fmt.Sprintf("server deletion waiting: %v", err)) + return + } + + tflog.Info(ctx, "server deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,server_id +func (r *serverResource) 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 server", + fmt.Sprintf("Expected import identifier with format: [project_id],[server_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + serverId := idParts[1] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...) + tflog.Info(ctx, "server state imported") +} + +func mapFields(ctx context.Context, serverResp *iaas.Server, model *Model) error { + if serverResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var serverId string + if model.ServerId.ValueString() != "" { + serverId = model.ServerId.ValueString() + } else if serverResp.Id != nil { + serverId = *serverResp.Id + } else { + return fmt.Errorf("Server id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + serverId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags)) + } + if serverResp.Labels != nil && len(*serverResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *serverResp.Labels) + if diags.HasError() { + return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + var createdAt basetypes.StringValue + if serverResp.CreatedAt != nil { + createdAtValue := *serverResp.CreatedAt + createdAt = types.StringValue(createdAtValue.Format(time.RFC3339)) + } + var updatedAt basetypes.StringValue + if serverResp.UpdatedAt != nil { + updatedAtValue := *serverResp.UpdatedAt + updatedAt = types.StringValue(updatedAtValue.Format(time.RFC3339)) + } + var launchedAt basetypes.StringValue + if serverResp.LaunchedAt != nil { + launchedAtValue := *serverResp.LaunchedAt + launchedAt = types.StringValue(launchedAtValue.Format(time.RFC3339)) + } + + model.ServerId = types.StringValue(serverId) + model.MachineType = types.StringPointerValue(serverResp.MachineType) + model.AvailabilityZone = types.StringPointerValue(serverResp.AvailabilityZone) + model.Name = types.StringPointerValue(serverResp.Name) + model.Labels = labels + model.ImageId = types.StringPointerValue(serverResp.ImageId) + model.KeypairName = types.StringPointerValue(serverResp.KeypairName) + model.AffinityGroup = types.StringPointerValue(serverResp.AffinityGroup) + model.CreatedAt = createdAt + model.UpdatedAt = updatedAt + model.LaunchedAt = launchedAt + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateServerPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + var bootVolume = &bootVolumeModel{} + if !(model.BootVolume.IsNull() || model.BootVolume.IsUnknown()) { + diags := model.BootVolume.As(ctx, bootVolume, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("convert boot volume object to struct: %w", core.DiagsToError(diags)) + } + } + + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + var bootVolumePayload *iaas.CreateServerPayloadBootVolume + if !bootVolume.SourceId.IsNull() && !bootVolume.SourceType.IsNull() { + bootVolumePayload = &iaas.CreateServerPayloadBootVolume{ + PerformanceClass: conversion.StringValueToPointer(bootVolume.PerformanceClass), + Size: conversion.Int64ValueToPointer(bootVolume.Size), + Source: &iaas.BootVolumeSource{ + Id: conversion.StringValueToPointer(bootVolume.SourceId), + Type: conversion.StringValueToPointer(bootVolume.SourceType), + }, + } + } + + var userData *string + if !model.UserData.IsNull() && !model.UserData.IsUnknown() { + encodedUserData := base64.StdEncoding.EncodeToString([]byte(model.UserData.ValueString())) + userData = &encodedUserData + } + + return &iaas.CreateServerPayload{ + AvailabilityZone: conversion.StringValueToPointer(model.AvailabilityZone), + BootVolume: bootVolumePayload, + ImageId: conversion.StringValueToPointer(model.ImageId), + KeypairName: conversion.StringValueToPointer(model.KeypairName), + Labels: &labels, + Name: conversion.StringValueToPointer(model.Name), + MachineType: conversion.StringValueToPointer(model.MachineType), + UserData: userData, + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateServerPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaas.UpdateServerPayload{ + Name: conversion.StringValueToPointer(model.Name), + Labels: &labels, + }, nil +} diff --git a/stackit/internal/services/iaas/server/resource_test.go b/stackit/internal/services/iaas/server/resource_test.go new file mode 100644 index 00000000..3a87a092 --- /dev/null +++ b/stackit/internal/services/iaas/server/resource_test.go @@ -0,0 +1,269 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + userData = "user_data" + base64EncodedUserData = "dXNlcl9kYXRh" + testTimestampValue = "2006-01-02T15:04:05Z" +) + +func testTimestamp() time.Time { + timestamp, _ := time.Parse(time.RFC3339, testTimestampValue) + return timestamp +} + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaas.Server + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + }, + &iaas.Server{ + Id: utils.Ptr("sid"), + }, + Model{ + Id: types.StringValue("pid,sid"), + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + Name: types.StringNull(), + AvailabilityZone: types.StringNull(), + Labels: types.MapNull(types.StringType), + ImageId: types.StringNull(), + KeypairName: types.StringNull(), + AffinityGroup: types.StringNull(), + UserData: types.StringNull(), + CreatedAt: types.StringNull(), + UpdatedAt: types.StringNull(), + LaunchedAt: types.StringNull(), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + }, + &iaas.Server{ + Id: utils.Ptr("sid"), + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + ImageId: utils.Ptr("image_id"), + KeypairName: utils.Ptr("keypair_name"), + AffinityGroup: utils.Ptr("group_id"), + CreatedAt: utils.Ptr(testTimestamp()), + UpdatedAt: utils.Ptr(testTimestamp()), + LaunchedAt: utils.Ptr(testTimestamp()), + }, + Model{ + Id: types.StringValue("pid,sid"), + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + ImageId: types.StringValue("image_id"), + KeypairName: types.StringValue("keypair_name"), + AffinityGroup: types.StringValue("group_id"), + CreatedAt: types.StringValue(testTimestampValue), + UpdatedAt: types.StringValue(testTimestampValue), + LaunchedAt: types.StringValue(testTimestampValue), + }, + true, + }, + { + "empty_labels", + Model{ + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + &iaas.Server{ + Id: utils.Ptr("sid"), + }, + Model{ + Id: types.StringValue("pid,sid"), + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + Name: types.StringNull(), + AvailabilityZone: types.StringNull(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + ImageId: types.StringNull(), + KeypairName: types.StringNull(), + AffinityGroup: types.StringNull(), + UserData: types.StringNull(), + CreatedAt: types.StringNull(), + UpdatedAt: types.StringNull(), + LaunchedAt: types.StringNull(), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaas.Server{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), 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.CreateServerPayload + isValid bool + }{ + { + "ok", + &Model{ + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + BootVolume: types.ObjectValueMust(bootVolumeTypes, map[string]attr.Value{ + "performance_class": types.StringValue("class"), + "size": types.Int64Value(1), + "source_type": types.StringValue("type"), + "source_id": types.StringValue("id"), + }), + ImageId: types.StringValue("image"), + KeypairName: types.StringValue("keypair"), + MachineType: types.StringValue("machine_type"), + UserData: types.StringValue(userData), + }, + &iaas.CreateServerPayload{ + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + BootVolume: &iaas.CreateServerPayloadBootVolume{ + PerformanceClass: utils.Ptr("class"), + Size: utils.Ptr(int64(1)), + Source: &iaas.BootVolumeSource{ + Type: utils.Ptr("type"), + Id: utils.Ptr("id"), + }, + }, + ImageId: utils.Ptr("image"), + KeypairName: utils.Ptr("keypair"), + MachineType: utils.Ptr("machine_type"), + UserData: utils.Ptr(base64EncodedUserData), + }, + 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.UpdateServerPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + &iaas.UpdateServerPayload{ + Name: utils.Ptr("name"), + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + 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/services/iaas/serviceaccountattach/resource.go b/stackit/internal/services/iaas/serviceaccountattach/resource.go new file mode 100644 index 00000000..1d41ed60 --- /dev/null +++ b/stackit/internal/services/iaas/serviceaccountattach/resource.go @@ -0,0 +1,299 @@ +package serviceaccountattach + +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/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 = &networkInterfaceAttachResource{} + _ resource.ResourceWithConfigure = &networkInterfaceAttachResource{} + _ resource.ResourceWithImportState = &networkInterfaceAttachResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + ServerId types.String `tfsdk:"server_id"` + ServiceAccountEmail types.String `tfsdk:"service_account_email"` +} + +// NewServiceAccountAttachResource is a helper function to simplify the provider implementation. +func NewServiceAccountAttachResource() resource.Resource { + return &networkInterfaceAttachResource{} +} + +// networkInterfaceAttachResource is the resource implementation. +type networkInterfaceAttachResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *networkInterfaceAttachResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_server_service_account_attach" +} + +// Configure adds the provider configured client to the resource. +func (r *networkInterfaceAttachResource) 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_server_service_account_attach", "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 *networkInterfaceAttachResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Service account attachment resource schema. Attaches a service account to a server. Must have a `region` specified in the provider configuration."), + Description: "Service account attachment resource schema. Attaches a service account to a server. 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 \"`project_id`,`server_id`,`service_account_email`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the service account attachment is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "service_account_email": schema.StringAttribute{ + Description: "The service account email.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *networkInterfaceAttachResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "server_id", serverId) + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail) + + // Create new service account attachment + _, err := r.client.AddServiceAccountToServer(ctx, projectId, serverId, serviceAccountEmail).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching service account to server", fmt.Sprintf("Calling API: %v", err)) + return + } + + idParts := []string{ + projectId, + serverId, + serviceAccountEmail, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Service account attachment created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *networkInterfaceAttachResource) 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 + } + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "server_id", serverId) + serviceAccountEmail := model.ServiceAccountEmail.ValueString() + ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail) + + serviceAccounts, err := r.client.ListServerServiceAccounts(ctx, projectId, serverId).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 service account attachment", fmt.Sprintf("Calling API: %v", err)) + return + } + + if serviceAccounts == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account attachment", "List of service accounts attached to the server is nil") + return + } + + if serviceAccounts.Items != nil { + for _, mail := range *serviceAccounts.Items { + if mail != serviceAccountEmail { + continue + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Service account attachment read") + return + } + } + + // no matching service account was found, the attachment no longer exists + resp.State.RemoveResource(ctx) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *networkInterfaceAttachResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update is not supported, all fields require replace +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *networkInterfaceAttachResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "server_id", serverId) + service_accountId := model.ServiceAccountEmail.ValueString() + ctx = tflog.SetField(ctx, "service_account_email", service_accountId) + + // Remove service_account from server + _, err := r.client.RemoveServiceAccountFromServer(ctx, projectId, serverId, service_accountId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing service account from server", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "Service account attachment deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,server_id +func (r *networkInterfaceAttachResource) 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 service_account attachment", + fmt.Sprintf("Expected import identifier with format: [project_id],[server_id],[service_account_email] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + serverId := idParts[1] + service_accountId := idParts[2] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + ctx = tflog.SetField(ctx, "service_account_email", service_accountId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("service_account_email"), service_accountId)...) + tflog.Info(ctx, "Service account attachment state imported") +} diff --git a/stackit/internal/services/iaas/volume/datasource.go b/stackit/internal/services/iaas/volume/datasource.go new file mode 100644 index 00000000..48870e49 --- /dev/null +++ b/stackit/internal/services/iaas/volume/datasource.go @@ -0,0 +1,202 @@ +package volume + +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-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/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// volumeDataSourceBetaCheckDone 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 volumeDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &volumeDataSource{} +) + +// NewVolumeDataSource is a helper function to simplify the provider implementation. +func NewVolumeDataSource() datasource.DataSource { + return &volumeDataSource{} +} + +// volumeDataSource is the data source implementation. +type volumeDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *volumeDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_volume" +} + +func (d *volumeDataSource) 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 !volumeDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_volume", "data source") + if resp.Diagnostics.HasError() { + return + } + volumeDataSourceBetaCheckDone = 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 resource. +func (r *volumeDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Volume resource schema. Must have a `region` specified in the provider configuration."), + Description: "Volume 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 \"`project_id`,`volume_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the volume is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "volume_id": schema.StringAttribute{ + Description: "The volume ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID of the server to which the volume is attached to.", + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the volume.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "The description of the volume.", + Computed: true, + }, + "availability_zone": schema.StringAttribute{ + Description: "The availability zone of the volume.", + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, + "performance_class": schema.StringAttribute{ + Description: "The performance class of the volume.", + Computed: true, + }, + "size": schema.Int64Attribute{ + Description: "The size of the volume in GB. It can only be updated to a larger value than the current size", + Computed: true, + }, + "source": schema.SingleNestedAttribute{ + Description: "The source of the volume. It can be either a volume, an image, a snapshot or a backup", + Computed: true, + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: "The type of the source. " + utils.SupportedValuesDocumentation(SupportedSourceTypes), + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The ID of the source, e.g. image ID", + Computed: true, + }, + }, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *volumeDataSource) 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 + } + projectId := model.ProjectId.ValueString() + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + volumeResp, err := d.client.GetVolume(ctx, projectId, volumeId).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 volume", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, volumeResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", 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, "volume read") +} diff --git a/stackit/internal/services/iaas/volume/resource.go b/stackit/internal/services/iaas/volume/resource.go new file mode 100644 index 00000000..10763b27 --- /dev/null +++ b/stackit/internal/services/iaas/volume/resource.go @@ -0,0 +1,608 @@ +package volume + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "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/objectplanmodifier" + "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-framework/types/basetypes" + "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/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" + "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 = &volumeResource{} + _ resource.ResourceWithConfigure = &volumeResource{} + _ resource.ResourceWithImportState = &volumeResource{} + + SupportedSourceTypes = []string{"volume", "image", "snapshot", "backup"} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + VolumeId types.String `tfsdk:"volume_id"` + Name types.String `tfsdk:"name"` + AvailabilityZone types.String `tfsdk:"availability_zone"` + Labels types.Map `tfsdk:"labels"` + Description types.String `tfsdk:"description"` + PerformanceClass types.String `tfsdk:"performance_class"` + Size types.Int64 `tfsdk:"size"` + ServerId types.String `tfsdk:"server_id"` + Source types.Object `tfsdk:"source"` +} + +// Struct corresponding to Model.Source +type sourceModel struct { + Type types.String `tfsdk:"type"` + Id types.String `tfsdk:"id"` +} + +// Types corresponding to sourceModel +var sourceTypes = map[string]attr.Type{ + "type": basetypes.StringType{}, + "id": basetypes.StringType{}, +} + +// NewVolumeResource is a helper function to simplify the provider implementation. +func NewVolumeResource() resource.Resource { + return &volumeResource{} +} + +// volumeResource is the resource implementation. +type volumeResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *volumeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_volume" +} + +// ConfigValidators validates the resource configuration +func (r *volumeResource) ConfigValidators(_ context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + resourcevalidator.AtLeastOneOf( + path.MatchRoot("source"), + path.MatchRoot("size"), + ), + } +} + +// Configure adds the provider configured client to the resource. +func (r *volumeResource) 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_volume", "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 *volumeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Volume resource schema. Must have a `region` specified in the provider configuration."), + Description: "Volume 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 \"`project_id`,`volume_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the volume is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "volume_id": schema.StringAttribute{ + Description: "The volume ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID of the server to which the volume is attached to.", + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the volume.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "description": schema.StringAttribute{ + Description: "The description of the volume.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(127), + }, + }, + "availability_zone": schema.StringAttribute{ + Description: "The availability zone of the volume.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Required: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, + "performance_class": schema.StringAttribute{ + Description: "The performance class of the volume.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "size": schema.Int64Attribute{ + Description: "The size of the volume in GB. It can only be updated to a larger value than the current size. Either `size` or `source` must be provided", + Optional: true, + }, + "source": schema.SingleNestedAttribute{ + Description: "The source of the volume. It can be either a volume, an image, a snapshot or a backup. Either `size` or `source` must be provided", + Optional: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), + }, + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: "The type of the source. " + utils.SupportedValuesDocumentation(SupportedSourceTypes), + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "id": schema.StringAttribute{ + Description: "The ID of the source, e.g. image ID", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *volumeResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + + var source = &sourceModel{} + if !(model.Source.IsNull() || model.Source.IsUnknown()) { + diags = model.Source.As(ctx, source, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model, source) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new volume + + volume, err := r.client.CreateVolume(ctx, projectId).CreateVolumePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Calling API: %v", err)) + return + } + + volumeId := *volume.Id + volume, err = wait.CreateVolumeWaitHandler(ctx, r.client, projectId, volumeId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("volume creation waiting: %v", err)) + return + } + + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + // Map response body to schema + err = mapFields(ctx, volume, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", 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, "Volume created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *volumeResource) 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 + } + projectId := model.ProjectId.ValueString() + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + volumeResp, err := r.client.GetVolume(ctx, projectId, volumeId).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 volume", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, volumeResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", 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, "volume read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *volumeResource) 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 + } + projectId := model.ProjectId.ValueString() + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing volume + updatedVolume, err := r.client.UpdateVolume(ctx, projectId, volumeId).UpdateVolumePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Resize existing volume + modelSize := conversion.Int64ValueToPointer(model.Size) + if modelSize != nil && updatedVolume.Size != nil { + // A volume can only be resized to larger values, otherwise an error occurs + if *modelSize < *updatedVolume.Size { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("The new volume size must be larger than the current size (%d GB)", *updatedVolume.Size)) + } else if *modelSize > *updatedVolume.Size { + payload := iaas.ResizeVolumePayload{ + Size: modelSize, + } + err := r.client.ResizeVolume(ctx, projectId, volumeId).ResizeVolumePayload(payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Resizing the volume, calling API: %v", err)) + } + // Update volume model because the API doesn't return a volume object as response + updatedVolume.Size = modelSize + } + } + err = mapFields(ctx, updatedVolume, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", 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, "volume updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *volumeResource) 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 + } + + projectId := model.ProjectId.ValueString() + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + // Delete existing volume + err := r.client.DeleteVolume(ctx, projectId, volumeId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting volume", fmt.Sprintf("Calling API: %v", err)) + return + } + _, err = wait.DeleteVolumeWaitHandler(ctx, r.client, projectId, volumeId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting volume", fmt.Sprintf("volume deletion waiting: %v", err)) + return + } + + tflog.Info(ctx, "volume deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,volume_id +func (r *volumeResource) 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 volume", + fmt.Sprintf("Expected import identifier with format: [project_id],[volume_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + volumeId := idParts[1] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("volume_id"), volumeId)...) + tflog.Info(ctx, "volume state imported") +} + +func mapFields(ctx context.Context, volumeResp *iaas.Volume, model *Model) error { + if volumeResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var volumeId string + if model.VolumeId.ValueString() != "" { + volumeId = model.VolumeId.ValueString() + } else if volumeResp.Id != nil { + volumeId = *volumeResp.Id + } else { + return fmt.Errorf("Volume id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + volumeId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if volumeResp.Labels != nil && len(*volumeResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *volumeResp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + + var sourceValues map[string]attr.Value + var sourceObject basetypes.ObjectValue + if volumeResp.Source == nil { + sourceObject = types.ObjectNull(sourceTypes) + } else { + sourceValues = map[string]attr.Value{ + "type": types.StringPointerValue(volumeResp.Source.Type), + "id": types.StringPointerValue(volumeResp.Source.Id), + } + sourceObject, diags = types.ObjectValue(sourceTypes, sourceValues) + if diags.HasError() { + return fmt.Errorf("creating source: %w", core.DiagsToError(diags)) + } + } + + model.VolumeId = types.StringValue(volumeId) + model.AvailabilityZone = types.StringPointerValue(volumeResp.AvailabilityZone) + model.Description = types.StringPointerValue(volumeResp.Description) + model.Name = types.StringPointerValue(volumeResp.Name) + model.Labels = labels + model.PerformanceClass = types.StringPointerValue(volumeResp.PerformanceClass) + model.ServerId = types.StringPointerValue(volumeResp.ServerId) + model.Size = types.Int64PointerValue(volumeResp.Size) + model.Source = sourceObject + return nil +} + +func toCreatePayload(ctx context.Context, model *Model, source *sourceModel) (*iaas.CreateVolumePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + var sourcePayload *iaas.VolumeSource + + if !source.Id.IsNull() && !source.Type.IsNull() { + sourcePayload = &iaas.VolumeSource{ + Id: conversion.StringValueToPointer(source.Id), + Type: conversion.StringValueToPointer(source.Type), + } + } + + return &iaas.CreateVolumePayload{ + AvailabilityZone: conversion.StringValueToPointer(model.AvailabilityZone), + Description: conversion.StringValueToPointer(model.Description), + Labels: &labels, + Name: conversion.StringValueToPointer(model.Name), + PerformanceClass: conversion.StringValueToPointer(model.PerformanceClass), + Size: conversion.Int64ValueToPointer(model.Size), + Source: sourcePayload, + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateVolumePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaas.UpdateVolumePayload{ + Description: conversion.StringValueToPointer(model.Description), + Name: conversion.StringValueToPointer(model.Name), + Labels: &labels, + }, nil +} diff --git a/stackit/internal/services/iaas/volume/resource_test.go b/stackit/internal/services/iaas/volume/resource_test.go new file mode 100644 index 00000000..819d594e --- /dev/null +++ b/stackit/internal/services/iaas/volume/resource_test.go @@ -0,0 +1,253 @@ +package volume + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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.Volume + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + }, + &iaas.Volume{ + Id: utils.Ptr("nid"), + }, + Model{ + Id: types.StringValue("pid,nid"), + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + Name: types.StringNull(), + AvailabilityZone: types.StringNull(), + Labels: types.MapNull(types.StringType), + Description: types.StringNull(), + PerformanceClass: types.StringNull(), + ServerId: types.StringNull(), + Size: types.Int64Null(), + Source: types.ObjectNull(sourceTypes), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + }, + &iaas.Volume{ + Id: utils.Ptr("nid"), + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + PerformanceClass: utils.Ptr("class"), + ServerId: utils.Ptr("sid"), + Size: utils.Ptr(int64(1)), + Source: &iaas.VolumeSource{}, + }, + Model{ + Id: types.StringValue("pid,nid"), + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + PerformanceClass: types.StringValue("class"), + ServerId: types.StringValue("sid"), + Size: types.Int64Value(1), + Source: types.ObjectValueMust(sourceTypes, map[string]attr.Value{ + "type": types.StringNull(), + "id": types.StringNull(), + }), + }, + true, + }, + { + "empty_labels", + Model{ + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + &iaas.Volume{ + Id: utils.Ptr("nid"), + }, + Model{ + Id: types.StringValue("pid,nid"), + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + Name: types.StringNull(), + AvailabilityZone: types.StringNull(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + Description: types.StringNull(), + PerformanceClass: types.StringNull(), + ServerId: types.StringNull(), + Size: types.Int64Null(), + Source: types.ObjectNull(sourceTypes), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaas.Volume{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), 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 + source *sourceModel + expected *iaas.CreateVolumePayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + PerformanceClass: types.StringValue("class"), + Size: types.Int64Value(1), + Source: types.ObjectValueMust(sourceTypes, map[string]attr.Value{ + "type": types.StringNull(), + "id": types.StringNull(), + }), + }, + &sourceModel{ + Type: types.StringValue("volume"), + Id: types.StringValue("id"), + }, + &iaas.CreateVolumePayload{ + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + PerformanceClass: utils.Ptr("class"), + Size: utils.Ptr(int64(1)), + Source: &iaas.VolumeSource{ + Type: utils.Ptr("volume"), + Id: utils.Ptr("id"), + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input, tt.source) + 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.UpdateVolumePayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + }, + &iaas.UpdateVolumePayload{ + Name: utils.Ptr("name"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + 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/services/iaas/volumeattach/resource.go b/stackit/internal/services/iaas/volumeattach/resource.go new file mode 100644 index 00000000..9dce2b5b --- /dev/null +++ b/stackit/internal/services/iaas/volumeattach/resource.go @@ -0,0 +1,305 @@ +package volumeattach + +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/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/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 = &volumeAttachResource{} + _ resource.ResourceWithConfigure = &volumeAttachResource{} + _ resource.ResourceWithImportState = &volumeAttachResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + ServerId types.String `tfsdk:"server_id"` + VolumeId types.String `tfsdk:"volume_id"` +} + +// NewVolumeAttachResource is a helper function to simplify the provider implementation. +func NewVolumeAttachResource() resource.Resource { + return &volumeAttachResource{} +} + +// volumeAttachResource is the resource implementation. +type volumeAttachResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *volumeAttachResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_server_volume_attach" +} + +// Configure adds the provider configured client to the resource. +func (r *volumeAttachResource) 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_server_volume_attach", "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 *volumeAttachResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Volume attachment resource schema. Attaches a volume to a server. Must have a `region` specified in the provider configuration."), + Description: "Volume attachment resource schema. Attaches a volume to a server. 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 \"`project_id`,`server_id`,`volume_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the volume attachment is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "volume_id": schema.StringAttribute{ + Description: "The volume ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *volumeAttachResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "server_id", serverId) + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + // Create new Volume attachment + + payload := iaas.AddVolumeToServerPayload{ + DeleteOnTermination: utils.Ptr(false), + } + _, err := r.client.AddVolumeToServer(ctx, projectId, serverId, volumeId).AddVolumeToServerPayload(payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching volume to server", fmt.Sprintf("Calling API: %v", err)) + return + } + + _, err = wait.AddVolumeToServerWaitHandler(ctx, r.client, projectId, serverId, volumeId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching volume to server", fmt.Sprintf("volume attachment waiting: %v", err)) + return + } + + idParts := []string{ + projectId, + serverId, + volumeId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Volume attachment created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *volumeAttachResource) 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 + } + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "server_id", serverId) + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + _, err := r.client.GetAttachedVolume(ctx, projectId, serverId, volumeId).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 volume attachment", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Volume attachment read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *volumeAttachResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Update is not supported, all fields require replace +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *volumeAttachResource) 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 + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "server_id", serverId) + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + // Remove volume from server + err := r.client.RemoveVolumeFromServer(ctx, projectId, serverId, volumeId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing volume from server", fmt.Sprintf("Calling API: %v", err)) + return + } + + _, err = wait.RemoveVolumeFromServerWaitHandler(ctx, r.client, projectId, serverId, volumeId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing volume from server", fmt.Sprintf("volume removal waiting: %v", err)) + return + } + + tflog.Info(ctx, "Volume attachment deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,server_id +func (r *volumeAttachResource) 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 volume attachment", + fmt.Sprintf("Expected import identifier with format: [project_id],[server_id],[volume_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + serverId := idParts[1] + volumeId := idParts[2] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("volume_id"), volumeId)...) + tflog.Info(ctx, "Volume attachment state imported") +} diff --git a/stackit/internal/services/sqlserverflex/user/resource.go b/stackit/internal/services/sqlserverflex/user/resource.go index 0dd38ebc..5356f0b3 100644 --- a/stackit/internal/services/sqlserverflex/user/resource.go +++ b/stackit/internal/services/sqlserverflex/user/resource.go @@ -100,7 +100,7 @@ func (r *userResource) Configure(ctx context.Context, req resource.ConfigureRequ // Schema defines the schema for the resource. func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { descriptions := map[string]string{ - "main": "[Warning: BETA] SQLServer Flex user resource schema. Must have a `region` specified in the provider configuration.", + "main": "SQLServer Flex user resource schema. Must have a `region` specified in the provider configuration.", "id": "Terraform's internal resource ID. It is structured as \"`project_id`,`instance_id`,`user_id`\".", "user_id": "User ID.", "instance_id": "ID of the SQLServer Flex instance.", diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index d4b36acb..9ef4c3ee 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -34,6 +34,10 @@ var ( ProjectId = os.Getenv("TF_ACC_PROJECT_ID") // ServerId is the id of a server used for some tests ServerId = getenv("TF_ACC_SERVER_ID", "") + // IaaSImageId is the id of an image used for IaaS acceptance tests. Once the stackit_image resource is implemented, we can remove this + IaaSImageId = getenv("TF_ACC_IMAGE_ID", "") + // IaaSNetworkInterfaceId is the id of a network interface used for IaaS acceptance tests. Once acceptance tests are merged, we can remove this + IaaSNetworkInterfaceId = getenv("TF_ACC_NETWORK_INTERFACE_ID", "") // TestProjectParentContainerID is the container id of the parent resource under which projects are created as part of the resource-manager acceptance tests TestProjectParentContainerID = os.Getenv("TF_ACC_TEST_PROJECT_PARENT_CONTAINER_ID") // TestProjectParentContainerID is the uuid of the parent resource under which projects are created as part of the resource-manager acceptance tests @@ -120,6 +124,7 @@ func IaaSProviderConfig() string { return ` provider "stackit" { region = "eu01" + enable_beta_resources = true }` } return fmt.Sprintf(` diff --git a/stackit/provider.go b/stackit/provider.go index 796417fe..620e604e 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -17,6 +17,15 @@ import ( 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" + iaasNetworkInterface "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkinterface" + iaasNetworkInterfaceAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkinterfaceattach" + iaasPublicIp "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/publicip" + iaasSecurityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygroup" + iaasSecurityGroupRule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygrouprule" + iaasServer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/server" + iaasServiceAccountAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/serviceaccountattach" + iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume" + iaasVolumeAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volumeattach" 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" @@ -400,6 +409,12 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasNetwork.NewNetworkDataSource, iaasNetworkArea.NewNetworkAreaDataSource, iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource, + iaasNetworkInterface.NewNetworkInterfaceDataSource, + iaasVolume.NewVolumeDataSource, + iaasPublicIp.NewPublicIpDataSource, + iaasServer.NewServerDataSource, + iaasSecurityGroup.NewSecurityGroupDataSource, + iaasSecurityGroupRule.NewSecurityGroupRuleDataSource, loadBalancer.NewLoadBalancerDataSource, logMeInstance.NewInstanceDataSource, logMeCredential.NewCredentialDataSource, @@ -444,6 +459,15 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasNetwork.NewNetworkResource, iaasNetworkArea.NewNetworkAreaResource, iaasNetworkAreaRoute.NewNetworkAreaRouteResource, + iaasNetworkInterface.NewNetworkInterfaceResource, + iaasVolume.NewVolumeResource, + iaasPublicIp.NewPublicIpResource, + iaasVolumeAttach.NewVolumeAttachResource, + iaasNetworkInterfaceAttach.NewNetworkInterfaceAttachResource, + iaasServiceAccountAttach.NewServiceAccountAttachResource, + iaasServer.NewServerResource, + iaasSecurityGroup.NewSecurityGroupResource, + iaasSecurityGroupRule.NewSecurityGroupRuleResource, loadBalancer.NewLoadBalancerResource, loadBalancerCredential.NewCredentialResource, loadBalancerObservabilityCredential.NewObservabilityCredentialResource,