diff --git a/docs/data-sources/network_interface.md b/docs/data-sources/network_interface.md new file mode 100644 index 00000000..1f668206 --- /dev/null +++ b/docs/data-sources/network_interface.md @@ -0,0 +1,47 @@ +--- +# 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. +- `ipv6` (String) The IPv6 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/security_group.md b/docs/data-sources/security_group.md new file mode 100644 index 00000000..4eaf8dd6 --- /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 +resource "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/server.md b/docs/data-sources/server.md new file mode 100644 index 00000000..e687a922 --- /dev/null +++ b/docs/data-sources/server.md @@ -0,0 +1,61 @@ +--- +# 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 + +- `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. +- `initial_networking` (Attributes) The initial networking setup for the server. A network ID or a list of network interfaces IDs can be provided (see [below for nested schema](#nestedatt--initial_networking)) +- `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. +- `server_group` (String) The server group the server is assigned to. +- `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`. + + + +### Nested Schema for `initial_networking` + +Read-Only: + +- `network_id` (String) The network ID +- `network_interface_ids` (List of String) List of network interface IDs +- `security_group_ids` (List of String) List of security groups the server is associated to diff --git a/docs/data-sources/volume.md b/docs/data-sources/volume.md new file mode 100644 index 00000000..ba903e6d --- /dev/null +++ b/docs/data-sources/volume.md @@ -0,0 +1,44 @@ +--- +# 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. + + + + +## 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 e5e4bbfe..ad84605c 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_interface.md b/docs/resources/network_interface.md new file mode 100644 index 00000000..7d9700d2 --- /dev/null +++ b/docs/resources/network_interface.md @@ -0,0 +1,51 @@ +--- +# 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 = ["1.2.3.4"] + security_groups = ["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. +- `ipv6` (String) The IPv6 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/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/server.md b/docs/resources/server.md new file mode 100644 index 00000000..5f3a4972 --- /dev/null +++ b/docs/resources/server.md @@ -0,0 +1,89 @@ +--- +# 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. +--- + +# 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 + +```terraform +resource "stackit_server" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "IMAGE_ID" + } + initial_networking = { + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_ids = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] + } + availability_zone = "eu01-1" + labels = { + "key" = "value" + } + machine_type = "t1.1" + keypair_name = "my_key_pair_name" +} +``` + + +## 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 + +- `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. +- `initial_networking` (Attributes) The initial networking setup for the server. A network ID or a list of network interfaces IDs can be provided (see [below for nested schema](#nestedatt--initial_networking)) +- `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 +- `server_group` (String) The server group the server is assigned to. +- `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`. + + + +### Nested Schema for `initial_networking` + +Optional: + +- `network_id` (String) The network ID +- `network_interface_ids` (List of String) List of network interface IDs +- `security_group_ids` (List of String) List of security groups the initial network is assigned to 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_security_group/data-source.tf b/examples/data-sources/stackit_security_group/data-source.tf new file mode 100644 index 00000000..b40f16d7 --- /dev/null +++ b/examples/data-sources/stackit_security_group/data-source.tf @@ -0,0 +1,4 @@ +resource "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_volume/resource.tf b/examples/data-sources/stackit_volume/resource.tf new file mode 100644 index 00000000..ad48eb29 --- /dev/null +++ b/examples/data-sources/stackit_volume/resource.tf @@ -0,0 +1,4 @@ +resource "stackit_volume" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_network_interface/resource.tf b/examples/resources/stackit_network_interface/resource.tf new file mode 100644 index 00000000..b3ee6f6c --- /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 = ["1.2.3.4"] + security_groups = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] +} \ No newline at end of file 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_server/resource.tf b/examples/resources/stackit_server/resource.tf new file mode 100644 index 00000000..92a7dbf2 --- /dev/null +++ b/examples/resources/stackit_server/resource.tf @@ -0,0 +1,19 @@ +resource "stackit_server" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "IMAGE_ID" + } + initial_networking = { + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_ids = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] + } + availability_zone = "eu01-1" + labels = { + "key" = "value" + } + machine_type = "t1.1" + keypair_name = "my_key_pair_name" +} \ No newline at end of file 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 c491bc8a..497bfb84 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.10.0 + github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.7-alpha github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.19.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.19.0 diff --git a/go.sum b/go.sum index dd8d6c28..8906ebf9 100644 --- a/go.sum +++ b/go.sum @@ -157,6 +157,8 @@ github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 h1:QIZfs6nJ/l2pOweH1 github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0/go.mod h1:MdZcRbs19s2NLeJmSLSoqTzm9IPIQhE1ZEMpo9gePq0= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.10.0 h1:bqZ1CqiQLcu//4zc859XwYT/IZFjnlV9QeemmDbCPxc= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.10.0/go.mod h1:hEsLOmcqMFG0ftXSYOF8YtDrclkA0E89msGsH69B/BU= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.7-alpha h1:jMEuK4M39S/WCF9p2k/qkoEhxqnL5gffJerK8FwhYRY= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.7-alpha/go.mod h1:b4KR6r+yWS2hsDkz6ebRqxgadB+ZsAZcG0oDfv5jeaY= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0 h1:/GwkGMD7ID5hSjdZs1l/Mj8waceCt7oj3TxHgBfEMDQ= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0/go.mod h1:wsO3+vXe1XiKLeCIctWAptaHQZ07Un7kmLTQ+drbj7w= github.com/stackitcloud/stackit-sdk-go/services/logme v0.19.0 h1:KcsF549yXOrm8zlqFCNV+lf2L4zvQuh4L2i3kgdWbOE= 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..1c336ab1 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -12,10 +12,16 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "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 +45,44 @@ 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", +} + func networkResourceConfig(name, nameservers string) string { return fmt.Sprintf(` resource "stackit_network" "network" { @@ -87,40 +131,137 @@ 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" + } + initial_networking = { + network_id = stackit_network.network.network_id + } + 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 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 string) string { + return fmt.Sprintf("%s\n\n%s", + testutil.IaaSProviderConfig(), + securityGroupResourceConfig(name), + ) +} + +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 +288,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,6 +332,265 @@ func TestAccIaaS(t *testing.T) { ), }, // Import + { + ResourceName: "stackit_network_area.network_area", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_network_area.network_area"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_network_area.network_area") + } + networkAreaId, ok := r.Primary.Attributes["network_area_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_area_id") + } + return fmt.Sprintf("%s,%s", testutil.OrganizationId, networkAreaId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: "stackit_network_area_route.network_area_route", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_network_area_route.network_area_route"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_network_area_route.network_area_route") + } + networkAreaId, ok := r.Primary.Attributes["network_area_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_area_id") + } + networkAreaRouteId, ok := r.Primary.Attributes["network_area_route_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_area_route_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.OrganizationId, networkAreaId, networkAreaRouteId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: testAccNetworkAreaConfig( + fmt.Sprintf("%s-updated", networkAreaResource["name"]), + networkAreaResource["networkrange0"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Network area + resource.TestCheckResourceAttr("stackit_network_area.network_area", "organization_id", networkAreaResource["organization_id"]), + resource.TestCheckResourceAttrSet("stackit_network_area.network_area", "network_area_id"), + resource.TestCheckResourceAttr("stackit_network_area.network_area", "name", fmt.Sprintf("%s-updated", networkAreaResource["name"])), + resource.TestCheckResourceAttr("stackit_network_area.network_area", "network_ranges.#", "1"), + resource.TestCheckResourceAttr("stackit_network_area.network_area", "network_ranges.0.prefix", networkAreaResource["networkrange0"]), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +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) { @@ -227,55 +609,57 @@ func TestAccIaaS(t *testing.T) { ImportStateVerifyIgnore: []string{"ipv4_prefix_length"}, // Field is not returned by the API }, { - ResourceName: "stackit_network_area.network_area", + ResourceName: "stackit_server.server", ImportStateIdFunc: func(s *terraform.State) (string, error) { - r, ok := s.RootModule().Resources["stackit_network_area.network_area"] + r, ok := s.RootModule().Resources["stackit_server.server"] if !ok { - return "", fmt.Errorf("couldn't find resource stackit_network_area.network_area") + return "", fmt.Errorf("couldn't find resource stackit_server.server") } - networkAreaId, ok := r.Primary.Attributes["network_area_id"] + serverId, ok := r.Primary.Attributes["server_id"] if !ok { - return "", fmt.Errorf("couldn't find attribute network_area_id") + return "", fmt.Errorf("couldn't find attribute server_id") } - return fmt.Sprintf("%s,%s", testutil.OrganizationId, networkAreaId), nil + return fmt.Sprintf("%s,%s", testutil.ProjectId, serverId), nil }, - ImportState: true, - ImportStateVerify: true, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"initial_networking", "boot_volume", "user_data"}, // Field is not mapped as it is only relevant on creation }, { - ResourceName: "stackit_network_area_route.network_area_route", + ResourceName: "stackit_network_interface.network_interface", ImportStateIdFunc: func(s *terraform.State) (string, error) { - r, ok := s.RootModule().Resources["stackit_network_area_route.network_area_route"] + r, ok := s.RootModule().Resources["stackit_network_interface.network_interface"] if !ok { - return "", fmt.Errorf("couldn't find resource stackit_network_area_route.network_area_route") + return "", fmt.Errorf("couldn't find resource stackit_network_interface.network_interface") } - networkAreaId, ok := r.Primary.Attributes["network_area_id"] + networkId, ok := r.Primary.Attributes["network_id"] if !ok { - return "", fmt.Errorf("couldn't find attribute network_area_id") + return "", fmt.Errorf("couldn't find attribute network_id") } - networkAreaRouteId, ok := r.Primary.Attributes["network_area_route_id"] + networkInterfaceId, ok := r.Primary.Attributes["network_interface_id"] if !ok { - return "", fmt.Errorf("couldn't find attribute network_area_route_id") + return "", fmt.Errorf("couldn't find attribute network_interface_id") } - return fmt.Sprintf("%s,%s,%s", testutil.OrganizationId, networkAreaId, networkAreaRouteId), nil + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, networkId, networkInterfaceId), nil }, ImportState: true, ImportStateVerify: true, }, // Update { - Config: resourceConfig( + Config: testAccServerConfig( fmt.Sprintf("%s-updated", networkResource["name"]), fmt.Sprintf( "[%q, %q]", networkResource["nameserver0"], networkResource["nameserver1"], ), - fmt.Sprintf("%s-updated", networkAreaResource["name"]), - networkAreaResource["networkrange0"], + fmt.Sprintf("%s-updated", serverResource["name"]), + updatedServerMachineType, + fmt.Sprintf("%s-updated", networkInterfaceResource["name"]), ), Check: resource.ComposeAggregateTestCheckFunc( - // Instance + // 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"])), @@ -283,12 +667,26 @@ func TestAccIaaS(t *testing.T) { resource.TestCheckResourceAttr("stackit_network.network", "nameservers.0", networkResource["nameserver0"]), resource.TestCheckResourceAttr("stackit_network.network", "nameservers.1", networkResource["nameserver1"]), - // Network area - resource.TestCheckResourceAttr("stackit_network_area.network_area", "organization_id", networkAreaResource["organization_id"]), - resource.TestCheckResourceAttrSet("stackit_network_area.network_area", "network_area_id"), - resource.TestCheckResourceAttr("stackit_network_area.network_area", "name", fmt.Sprintf("%s-updated", networkAreaResource["name"])), - resource.TestCheckResourceAttr("stackit_network_area.network_area", "network_ranges.#", "1"), - resource.TestCheckResourceAttr("stackit_network_area.network_area", "network_ranges.0.prefix", networkAreaResource["networkrange0"]), + // 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 @@ -296,7 +694,83 @@ func TestAccIaaS(t *testing.T) { }) } -func testAccCheckIaaSDestroy(s *terraform.State) error { +func TestAccIaaSSecurityGroup(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIaaSSecurityGroupDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: resourceConfigSecurityGroup(securityGroupResource["name"]), + 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", securityGroupResource["name"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "labels.label1", securityGroupResource["label1"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "description", securityGroupResource["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 + } + `, + resourceConfigSecurityGroup(securityGroupResource["name"]), + ), + 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"]), + ), + }, + // 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, + }, + // Update + { + Config: resourceConfigSecurityGroup( + fmt.Sprintf("%s-updated", securityGroupResource["name"]), + ), + 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 testAccCheckNetworkAreaDestroy(s *terraform.State) error { ctx := context.Background() var client *iaas.APIClient var err error @@ -313,6 +787,140 @@ func testAccCheckIaaSDestroy(s *terraform.State) error { return fmt.Errorf("creating client: %w", err) } + // network areas + networkAreasToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_network_area" { + continue + } + networkAreaId := strings.Split(rs.Primary.ID, core.Separator)[1] + networkAreasToDestroy = append(networkAreasToDestroy, networkAreaId) + } + + networkAreasResp, err := client.ListNetworkAreasExecute(ctx, testutil.OrganizationId) + if err != nil { + return fmt.Errorf("getting networkAreasResp: %w", err) + } + + networkAreas := *networkAreasResp.Items + for i := range networkAreas { + if networkAreas[i].AreaId == nil { + continue + } + if utils.Contains(networkAreasToDestroy, *networkAreas[i].AreaId) { + err := client.DeleteNetworkAreaExecute(ctx, testutil.OrganizationId, *networkAreas[i].AreaId) + if err != nil { + return fmt.Errorf("destroying network area %s during CheckDestroy: %w", *networkAreas[i].AreaId, err) + } + } + } + return nil +} + +func testAccCheckIaaSVolumeDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaasalpha.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaasalpha.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaasalpha.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 *iaasalpha.APIClient + var client *iaas.APIClient + var err error + var alphaErr error + if testutil.IaaSCustomEndpoint == "" { + alphaClient, alphaErr = iaasalpha.NewAPIClient( + config.WithRegion("eu01"), + ) + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + alphaClient, alphaErr = iaasalpha.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" { @@ -341,30 +949,50 @@ func testAccCheckIaaSDestroy(s *terraform.State) error { } } - // network areas - networkAreasToDestroy := []string{} + return nil +} + +func testAccCheckIaaSSecurityGroupDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaasalpha.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaasalpha.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaasalpha.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_network_area" { + if rs.Type != "stackit_security_group" { continue } - networkAreaId := strings.Split(rs.Primary.ID, core.Separator)[1] - networkAreasToDestroy = append(networkAreasToDestroy, networkAreaId) + // security group terraform ID: "[project_id],[security_group_id]" + securityGroupId := strings.Split(rs.Primary.ID, core.Separator)[1] + securityGroupsToDestroy = append(securityGroupsToDestroy, securityGroupId) } - networkAreasResp, err := client.ListNetworkAreasExecute(ctx, testutil.OrganizationId) + securityGroupsResp, err := client.ListSecurityGroupsExecute(ctx, testutil.ProjectId) if err != nil { - return fmt.Errorf("getting networkAreasResp: %w", err) + return fmt.Errorf("getting securityGroupsResp: %w", err) } - networkAreas := *networkAreasResp.Items - for i := range networkAreas { - if networkAreas[i].AreaId == nil { + securityGroups := *securityGroupsResp.Items + for i := range securityGroups { + if securityGroups[i].Id == nil { continue } - if utils.Contains(networkAreasToDestroy, *networkAreas[i].AreaId) { - err := client.DeleteNetworkAreaExecute(ctx, testutil.OrganizationId, *networkAreas[i].AreaId) + if utils.Contains(securityGroupsToDestroy, *securityGroups[i].Id) { + err := client.DeleteSecurityGroupExecute(ctx, testutil.ProjectId, *securityGroups[i].Id) if err != nil { - return fmt.Errorf("destroying network area %s during CheckDestroy: %w", *networkAreas[i].AreaId, err) + return fmt.Errorf("destroying security group %s during CheckDestroy: %w", *securityGroups[i].Id, err) } } } diff --git a/stackit/internal/services/iaas/networkinterface/datasource.go b/stackit/internal/services/iaas/networkinterface/datasource.go new file mode 100644 index 00000000..f190857a --- /dev/null +++ b/stackit/internal/services/iaas/networkinterface/datasource.go @@ -0,0 +1,210 @@ +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/iaasalpha" + "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 *iaasalpha.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 *iaasalpha.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 = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.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, + }, + "ipv6": schema.StringAttribute{ + Description: "The IPv6 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..bc0de6d3 --- /dev/null +++ b/stackit/internal/services/iaas/networkinterface/resource.go @@ -0,0 +1,637 @@ +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/iaasalpha" + "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"` + IPv6 types.String `tfsdk:"ipv6"` + 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 *iaasalpha.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 *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.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, "IaaSalpha 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, + }, + "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(), + }, + }, + "ipv6": schema.StringAttribute{ + Description: "The IPv6 address.", + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.IP(), + }, + }, + "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, + }, + "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, + }, + }, + } +} + +// 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 *iaasalpha.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.IPv6 = types.StringPointerValue(networkInterfaceResp.Ipv6) + 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) (*iaasalpha.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 := []iaasalpha.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, iaasalpha.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 &iaasalpha.CreateNICPayload{ + AllowedAddresses: &allowedAddressesPayload, + SecurityGroups: &modelSecurityGroups, + Labels: labelPayload, + Name: conversion.StringValueToPointer(model.Name), + Device: conversion.StringValueToPointer(model.Device), + Ipv4: conversion.StringValueToPointer(model.IPv4), + Ipv6: conversion.StringValueToPointer(model.IPv6), + Mac: conversion.StringValueToPointer(model.Mac), + Type: conversion.StringValueToPointer(model.Type), + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaasalpha.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 := []iaasalpha.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, iaasalpha.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 &iaasalpha.UpdateNICPayload{ + AllowedAddresses: &allowedAddressesPayload, + SecurityGroups: &modelSecurityGroups, + Labels: labelPayload, + Name: conversion.StringValueToPointer(model.Name), + Device: conversion.StringValueToPointer(model.Device), + Ipv4: conversion.StringValueToPointer(model.IPv4), + Ipv6: conversion.StringValueToPointer(model.IPv6), + Mac: conversion.StringValueToPointer(model.Mac), + Type: conversion.StringValueToPointer(model.Type), + }, 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..4b785564 --- /dev/null +++ b/stackit/internal/services/iaas/networkinterface/resource_test.go @@ -0,0 +1,275 @@ +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/iaasalpha" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaasalpha.NIC + expected Model + isValid bool + }{ + { + "id_ok", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + &iaasalpha.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(), + IPv6: 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"), + }, + &iaasalpha.NIC{ + Id: utils.Ptr("nicid"), + Name: utils.Ptr("name"), + AllowedAddresses: &[]iaasalpha.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"), + IPv6: types.StringValue("ipv6"), + 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"), + }), + }, + &iaasalpha.NIC{ + Id: utils.Ptr("nicid"), + AllowedAddresses: &[]iaasalpha.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"), + }, + &iaasalpha.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 *iaasalpha.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"), + }), + }, + &iaasalpha.CreateNICPayload{ + Name: utils.Ptr("name"), + SecurityGroups: &[]string{ + "sg1", + "sg2", + }, + AllowedAddresses: &[]iaasalpha.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 *iaasalpha.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"), + }), + }, + &iaasalpha.UpdateNICPayload{ + Name: utils.Ptr("name"), + SecurityGroups: &[]string{ + "sg1", + "sg2", + }, + AllowedAddresses: &[]iaasalpha.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/securitygroup/datasource.go b/stackit/internal/services/iaas/securitygroup/datasource.go new file mode 100644 index 00000000..0de00660 --- /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/iaasalpha" + "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 *iaasalpha.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 *iaasalpha.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 = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.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, "iaasalpha 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..05a63e02 --- /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/iaasalpha" + "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 *iaasalpha.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 *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.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, "iaasalpha 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.V1alpha1UpdateSecurityGroup(ctx, projectId, securityGroupId).V1alpha1UpdateSecurityGroupPayload(*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 *iaasalpha.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) (*iaasalpha.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 &iaasalpha.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) (*iaasalpha.V1alpha1UpdateSecurityGroupPayload, 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 &iaasalpha.V1alpha1UpdateSecurityGroupPayload{ + 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..4e4ea5c3 --- /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/iaasalpha" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaasalpha.SecurityGroup + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + }, + &iaasalpha.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{}, + &iaasalpha.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"), + }, + &iaasalpha.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"), + }, + &iaasalpha.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 *iaasalpha.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"), + }, + &iaasalpha.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 *iaasalpha.V1alpha1UpdateSecurityGroupPayload + 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"), + }, + &iaasalpha.V1alpha1UpdateSecurityGroupPayload{ + 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/server/datasource.go b/stackit/internal/services/iaas/server/datasource.go new file mode 100644 index 00000000..55fa1285 --- /dev/null +++ b/stackit/internal/services/iaas/server/datasource.go @@ -0,0 +1,244 @@ +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/iaasalpha" + "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 *iaasalpha.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 *iaasalpha.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 = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.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, "iaasalpha 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, + }, + "initial_networking": schema.SingleNestedAttribute{ + Description: "The initial networking setup for the server. A network ID or a list of network interfaces IDs can be provided", + Computed: true, + Attributes: map[string]schema.Attribute{ + "network_id": schema.StringAttribute{ + Description: "The network ID", + Computed: true, + }, + "network_interface_ids": schema.ListAttribute{ + ElementType: types.StringType, + Description: "List of network interface IDs", + Computed: true, + }, + "security_group_ids": schema.ListAttribute{ + ElementType: types.StringType, + Description: "List of security groups the server is associated to", + 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, + }, + "server_group": schema.StringAttribute{ + Description: "The server 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..abaed25b --- /dev/null +++ b/stackit/internal/services/iaas/server/resource.go @@ -0,0 +1,795 @@ +package server + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "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/listplanmodifier" + "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/iaasalpha" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha/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"` + InitialNetworking types.Object `tfsdk:"initial_networking"` + 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"` + ServerGroup types.String `tfsdk:"server_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.InitialNetwork +type initialNetworkModel struct { + NetworkId types.String `tfsdk:"network_id"` + NetworkInterfaceIds types.List `tfsdk:"network_interface_ids"` + SecurityGroupIds types.List `tfsdk:"security_group_ids"` +} + +// Types corresponding to initialNetworkModel +var initialNetworkTypes = map[string]attr.Type{ + "network_id": basetypes.StringType{}, + "network_interface_ids": basetypes.ListType{ElemType: types.StringType}, + "security_group_ids": basetypes.ListType{ElemType: types.StringType}, +} + +// 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 *iaasalpha.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"), + ), + } +} + +// 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 *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.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, "iaasalpha 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: features.AddBetaDescription("Server resource schema. Must have a `region` specified in the provider configuration."), + 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"), + }, + }, + "initial_networking": schema.SingleNestedAttribute{ + Description: "The initial networking setup for the server. A network ID or a list of network interfaces IDs can be provided", + Optional: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), + }, + Attributes: map[string]schema.Attribute{ + "network_id": schema.StringAttribute{ + Description: "The network ID", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRoot("initial_networking").AtName("network_interface_ids"), + ), + validate.UUID(), + }, + }, + "security_group_ids": schema.ListAttribute{ + ElementType: types.StringType, + Description: "List of security groups the initial network is assigned to", + Optional: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + ), + listvalidator.ConflictsWith( + path.MatchRoot("initial_networking").AtName("network_interface_ids"), + ), + listvalidator.AlsoRequires( + path.MatchRoot("initial_networking").AtName("network_id"), + ), + }, + }, + "network_interface_ids": schema.ListAttribute{ + ElementType: types.StringType, + Description: "List of network interface IDs", + Optional: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + Validators: []validator.List{ + listvalidator.ConflictsWith( + path.MatchRoot("initial_networking").AtName("network_id"), + ), + listvalidator.ValueStringsAre( + validate.UUID(), + ), + }, + }, + }, + Validators: []validator.Object{ + objectvalidator.AtLeastOneOf( + path.MatchRoot("initial_networking").AtName("network_id"), + path.MatchRoot("initial_networking").AtName("network_interface_ids"), + ), + }, + }, + "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, + }, + "server_group": schema.StringAttribute{ + Description: "The server 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, + }, + "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.V1alpha1UpdateServer(ctx, projectId, serverId).V1alpha1UpdateServerPayload(*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 := iaasalpha.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 *iaasalpha.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.Image) + model.KeypairName = types.StringPointerValue(serverResp.Keypair) + model.ServerGroup = types.StringPointerValue(serverResp.ServerGroup) + model.CreatedAt = createdAt + model.UpdatedAt = updatedAt + model.LaunchedAt = launchedAt + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaasalpha.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)) + } + } + + var initialNetwork = &initialNetworkModel{} + if !(model.InitialNetworking.IsNull() || model.InitialNetworking.IsUnknown()) { + diags := model.InitialNetworking.As(ctx, initialNetwork, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("convert initial network 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 *iaasalpha.CreateServerPayloadBootVolume + if !bootVolume.SourceId.IsNull() && !bootVolume.SourceType.IsNull() { + bootVolumePayload = &iaasalpha.CreateServerPayloadBootVolume{ + PerformanceClass: conversion.StringValueToPointer(bootVolume.PerformanceClass), + Size: conversion.Int64ValueToPointer(bootVolume.Size), + Source: &iaasalpha.BootVolumeSource{ + Id: conversion.StringValueToPointer(bootVolume.SourceId), + Type: conversion.StringValueToPointer(bootVolume.SourceType), + }, + } + } + + var initialNetworkPayload *iaasalpha.CreateServerPayloadNetworking + var securityGroups *[]string + if !initialNetwork.NetworkId.IsNull() { + initialNetworkPayload = &iaasalpha.CreateServerPayloadNetworking{ + CreateServerNetworking: &iaasalpha.CreateServerNetworking{ + NetworkId: conversion.StringValueToPointer(initialNetwork.NetworkId), + }, + } + + securityGroups, err = conversion.StringListToPointer(initialNetwork.SecurityGroupIds) + if err != nil { + return nil, fmt.Errorf("converting list of security groups to string list pointer: %w", err) + } + } else if !initialNetwork.NetworkInterfaceIds.IsNull() { + nicIds, err := conversion.StringListToPointer(initialNetwork.NetworkInterfaceIds) + if err != nil { + return nil, fmt.Errorf("converting list of network interface IDs to string list pointer: %w", err) + } + initialNetworkPayload = &iaasalpha.CreateServerPayloadNetworking{ + CreateServerNetworkingWithNics: &iaasalpha.CreateServerNetworkingWithNics{ + NicIds: nicIds, + }, + } + } + + var userData *string + if !model.UserData.IsNull() && !model.UserData.IsUnknown() { + encodedUserData := base64.StdEncoding.EncodeToString([]byte(model.UserData.ValueString())) + userData = &encodedUserData + } + + return &iaasalpha.CreateServerPayload{ + AvailabilityZone: conversion.StringValueToPointer(model.AvailabilityZone), + BootVolume: bootVolumePayload, + Image: conversion.StringValueToPointer(model.ImageId), + Keypair: conversion.StringValueToPointer(model.KeypairName), + Networking: initialNetworkPayload, + SecurityGroups: securityGroups, + 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) (*iaasalpha.V1alpha1UpdateServerPayload, 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 &iaasalpha.V1alpha1UpdateServerPayload{ + 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..b809a276 --- /dev/null +++ b/stackit/internal/services/iaas/server/resource_test.go @@ -0,0 +1,322 @@ +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/iaasalpha" +) + +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 *iaasalpha.Server + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + }, + &iaasalpha.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(), + ServerGroup: 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"), + }, + &iaasalpha.Server{ + Id: utils.Ptr("sid"), + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Image: utils.Ptr("image_id"), + Keypair: utils.Ptr("keypair_name"), + ServerGroup: 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"), + ServerGroup: 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{}), + }, + &iaasalpha.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(), + ServerGroup: 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"), + }, + &iaasalpha.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 *iaasalpha.CreateServerPayload + isValid bool + }{ + { + "create_with_network", + &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"), + }), + InitialNetworking: types.ObjectValueMust(initialNetworkTypes, map[string]attr.Value{ + "network_id": types.StringValue("nid"), + "security_group_ids": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("group1"), + types.StringValue("group2"), + }), + "network_interface_ids": types.ListNull(types.StringType), + }), + ImageId: types.StringValue("image"), + KeypairName: types.StringValue("keypair"), + MachineType: types.StringValue("machine_type"), + UserData: types.StringValue(userData), + }, + &iaasalpha.CreateServerPayload{ + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Networking: &iaasalpha.CreateServerPayloadNetworking{ + CreateServerNetworking: &iaasalpha.CreateServerNetworking{ + NetworkId: utils.Ptr("nid"), + }, + }, + SecurityGroups: utils.Ptr([]string{"group1", "group2"}), + BootVolume: &iaasalpha.CreateServerPayloadBootVolume{ + PerformanceClass: utils.Ptr("class"), + Size: utils.Ptr(int64(1)), + Source: &iaasalpha.BootVolumeSource{ + Type: utils.Ptr("type"), + Id: utils.Ptr("id"), + }, + }, + Image: utils.Ptr("image"), + Keypair: utils.Ptr("keypair"), + MachineType: utils.Ptr("machine_type"), + UserData: utils.Ptr(base64EncodedUserData), + }, + true, + }, + { + "create_with_network_interface_ids", + &Model{ + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + InitialNetworking: types.ObjectValueMust(initialNetworkTypes, map[string]attr.Value{ + "network_id": types.StringNull(), + "security_group_ids": types.ListNull(types.StringType), + "network_interface_ids": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("nic1"), + types.StringValue("nic2"), + }), + }), + ImageId: types.StringValue("image"), + KeypairName: types.StringValue("keypair"), + MachineType: types.StringValue("machine_type"), + UserData: types.StringValue(userData), + }, + &iaasalpha.CreateServerPayload{ + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Networking: &iaasalpha.CreateServerPayloadNetworking{ + CreateServerNetworkingWithNics: &iaasalpha.CreateServerNetworkingWithNics{ + NicIds: utils.Ptr([]string{"nic1", "nic2"}), + }, + }, + Image: utils.Ptr("image"), + Keypair: 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 *iaasalpha.V1alpha1UpdateServerPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + &iaasalpha.V1alpha1UpdateServerPayload{ + 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/volume/datasource.go b/stackit/internal/services/iaas/volume/datasource.go new file mode 100644 index 00000000..e6c422af --- /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/iaasalpha" + "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 *iaasalpha.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 *iaasalpha.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 = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.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, "iaasalpha 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..2b14002e --- /dev/null +++ b/stackit/internal/services/iaas/volume/resource.go @@ -0,0 +1,611 @@ +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/iaasalpha" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha/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 *iaasalpha.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 *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.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, "iaasalpha 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, + Validators: []validator.Int64{ + validate.OnlyUpdateToLargerValue(), + }, + }, + "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 := iaasalpha.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 *iaasalpha.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) (*iaasalpha.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 *iaasalpha.VolumeSource + + if !source.Id.IsNull() && !source.Type.IsNull() { + sourcePayload = &iaasalpha.VolumeSource{ + Id: conversion.StringValueToPointer(source.Id), + Type: conversion.StringValueToPointer(source.Type), + } + } + + return &iaasalpha.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) (*iaasalpha.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 &iaasalpha.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..d0b7338d --- /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/iaasalpha" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaasalpha.Volume + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + }, + &iaasalpha.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"), + }, + &iaasalpha.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: &iaasalpha.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{}), + }, + &iaasalpha.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"), + }, + &iaasalpha.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 *iaasalpha.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"), + }, + &iaasalpha.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: &iaasalpha.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 *iaasalpha.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"), + }, + &iaasalpha.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/testutil/testutil.go b/stackit/internal/testutil/testutil.go index d4b36acb..7f8f8ff8 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -34,6 +34,8 @@ 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", "") // 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 +122,7 @@ func IaaSProviderConfig() string { return ` provider "stackit" { region = "eu01" + enable_beta_resources = true }` } return fmt.Sprintf(` diff --git a/stackit/internal/validate/int64validate.go b/stackit/internal/validate/int64validate.go new file mode 100644 index 00000000..616a954b --- /dev/null +++ b/stackit/internal/validate/int64validate.go @@ -0,0 +1,87 @@ +package validate + +import ( + "context" + "fmt" + _ "time/tzdata" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +type Int64Validator struct { + description string + markdownDescription string + validate Int64ValidationFn +} + +type Int64ValidationFn func(context.Context, validator.Int64Request, *validator.Int64Response) + +var _ = validator.Int64(&Int64Validator{}) + +func (v *Int64Validator) Description(_ context.Context) string { + return v.description +} + +func (v *Int64Validator) MarkdownDescription(_ context.Context) string { + return v.markdownDescription +} + +func (v *Int64Validator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { // nolint:gocritic // function signature required by Terraform + if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() { + return + } + v.validate(ctx, req, resp) +} + +func OnlyUpdateToLargerValue() *Int64Validator { + description := "value can only be updated to a larger value than the current one (%d)" + + return &Int64Validator{ + description: description, + validate: func(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { + attributePath := req.Path + var currentAttributeValue attr.Value + diags := req.Config.GetAttribute(ctx, attributePath, currentAttributeValue) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + "get current value of the attribute failed, path might be wrong", + string(req.ConfigValue.ValueInt64()), + )) + } + + // If the current path value is null or unknown, there is no validation to be done + if currentAttributeValue.IsNull() || currentAttributeValue.IsUnknown() { + return + } + + terraformValue, err := currentAttributeValue.ToTerraformValue(ctx) + if err != nil { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + fmt.Sprintf("converting current attribute value to terraform value: %w", err), + string(req.ConfigValue.ValueInt64()), + )) + } + var currentIntValue int64 + if err := terraformValue.Copy().As(¤tIntValue); err != nil { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + fmt.Sprintf("converting current attribute value to int64: %w", err), + string(req.ConfigValue.ValueInt64()), + )) + } + + if currentIntValue <= req.ConfigValue.ValueInt64() { + resp.Diagnostics.AddAttributeError( + req.Path, + fmt.Sprintf(description, currentIntValue), + string(req.ConfigValue.ValueInt64()), + ) + } + }, + } +} diff --git a/stackit/internal/validate/int64validate_test.txt b/stackit/internal/validate/int64validate_test.txt new file mode 100644 index 00000000..6d19a616 --- /dev/null +++ b/stackit/internal/validate/int64validate_test.txt @@ -0,0 +1,61 @@ +// package validate + +// import ( +// "context" +// "testing" + +// "github.com/hashicorp/terraform-plugin-framework/path" +// "github.com/hashicorp/terraform-plugin-framework/schema/validator" +// "github.com/hashicorp/terraform-plugin-framework/tfsdk" +// "github.com/hashicorp/terraform-plugin-framework/types" +// ) + +// func TestOnlyUpdateToLargerValue(t *testing.T) { +// tests := []struct { +// description string +// input int64 +// currentValue int64 +// isValid bool +// }{ +// // { +// // "ok", +// // "cae27bba-c43d-498a-861e-d11d241c4ff8", +// // true, +// // }, +// // { +// // "too short", +// // "a-b-c-d", +// // false, +// // }, +// // { +// // "Empty", +// // "", +// // false, +// // }, +// // { +// // "not UUID", +// // "www-541-%", +// // false, +// // }, +// } +// for _, tt := range tests { +// t.Run(tt.description, func(t *testing.T) { +// r := validator.Int64Response{} +// path := path.Root("test") +// OnlyUpdateToLargerValue().ValidateInt64(context.Background(), validator.Int64Request{ +// Path: path, +// ConfigValue: types.Int64Value(tt.input), +// Config: tfsdk.Config{ +// Schema: , +// }, +// }, &r) + +// if !tt.isValid && !r.Diagnostics.HasError() { +// t.Fatalf("Should have failed") +// } +// if tt.isValid && r.Diagnostics.HasError() { +// t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors()) +// } +// }) +// } +// } diff --git a/stackit/internal/validate/validate.go b/stackit/internal/validate/stringvalidate.go similarity index 85% rename from stackit/internal/validate/validate.go rename to stackit/internal/validate/stringvalidate.go index 8b6ec78e..e9203ed7 100644 --- a/stackit/internal/validate/validate.go +++ b/stackit/internal/validate/stringvalidate.go @@ -24,35 +24,35 @@ const ( FullVersionRegex = `^\d+\.\d+.\d+?$` ) -type Validator struct { +type StringValidator struct { description string markdownDescription string - validate ValidationFn + validate StringValidationFn } -type ValidationFn func(context.Context, validator.StringRequest, *validator.StringResponse) +type StringValidationFn func(context.Context, validator.StringRequest, *validator.StringResponse) -var _ = validator.String(&Validator{}) +var _ = validator.String(&StringValidator{}) -func (v *Validator) Description(_ context.Context) string { +func (v *StringValidator) Description(_ context.Context) string { return v.description } -func (v *Validator) MarkdownDescription(_ context.Context) string { +func (v *StringValidator) MarkdownDescription(_ context.Context) string { return v.markdownDescription } -func (v *Validator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { // nolint:gocritic // function signature required by Terraform +func (v *StringValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { // nolint:gocritic // function signature required by Terraform if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() { return } v.validate(ctx, req, resp) } -func UUID() *Validator { +func UUID() *StringValidator { description := "value must be an UUID" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { if _, err := uuid.Parse(req.ConfigValue.ValueString()); err != nil { @@ -66,10 +66,10 @@ func UUID() *Validator { } } -func IP() *Validator { +func IP() *StringValidator { description := "value must be an IP address" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { if net.ParseIP(req.ConfigValue.ValueString()) == nil { @@ -83,9 +83,9 @@ func IP() *Validator { } } -func RecordSet() *Validator { +func RecordSet() *StringValidator { const typePath = "type" - return &Validator{ + return &StringValidator{ description: "value must be a valid record set", validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { recordType := basetypes.StringValue{} @@ -122,10 +122,10 @@ func RecordSet() *Validator { } } -func NoSeparator() *Validator { +func NoSeparator() *StringValidator { description := fmt.Sprintf("value must not contain identifier separator '%s'", core.Separator) - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { if strings.Contains(req.ConfigValue.ValueString(), core.Separator) { @@ -139,10 +139,10 @@ func NoSeparator() *Validator { } } -func NonLegacyProjectRole() *Validator { +func NonLegacyProjectRole() *StringValidator { description := "legacy roles are not supported" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { if utils.IsLegacyProjectRole(req.ConfigValue.ValueString()) { @@ -156,10 +156,10 @@ func NonLegacyProjectRole() *Validator { } } -func MinorVersionNumber() *Validator { +func MinorVersionNumber() *StringValidator { description := "value must be a minor version number, without a leading 'v': '[MAJOR].[MINOR]'" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { exp := MajorMinorVersionRegex @@ -176,10 +176,10 @@ func MinorVersionNumber() *Validator { } } -func VersionNumber() *Validator { +func VersionNumber() *StringValidator { description := "value must be a version number, without a leading 'v': '[MAJOR].[MINOR]' or '[MAJOR].[MINOR].[PATCH]'" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { minorVersionExp := MajorMinorVersionRegex @@ -200,10 +200,10 @@ func VersionNumber() *Validator { } } -func RFC3339SecondsOnly() *Validator { +func RFC3339SecondsOnly() *StringValidator { description := "value must be in RFC339 format (seconds only)" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { t, err := time.Parse(time.RFC3339, req.ConfigValue.ValueString()) @@ -228,10 +228,10 @@ func RFC3339SecondsOnly() *Validator { } } -func CIDR() *Validator { +func CIDR() *StringValidator { description := "value must be in CIDR notation" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { _, _, err := net.ParseCIDR(req.ConfigValue.ValueString()) @@ -246,10 +246,10 @@ func CIDR() *Validator { } } -func Rrule() *Validator { +func Rrule() *StringValidator { description := "value must be in a valid RRULE format" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { // The go library rrule-go expects \n before RRULE (to be a newline and not a space) diff --git a/stackit/internal/validate/validate_test.go b/stackit/internal/validate/stringvalidate_test.go similarity index 100% rename from stackit/internal/validate/validate_test.go rename to stackit/internal/validate/stringvalidate_test.go diff --git a/stackit/provider.go b/stackit/provider.go index 796417fe..56c2ccb5 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -17,6 +17,10 @@ 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" + iaasSecurityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygroup" + iaasServer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/server" + iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume" 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 +404,10 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasNetwork.NewNetworkDataSource, iaasNetworkArea.NewNetworkAreaDataSource, iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource, + iaasNetworkInterface.NewNetworkInterfaceDataSource, + iaasVolume.NewVolumeDataSource, + iaasServer.NewServerDataSource, + iaasSecurityGroup.NewSecurityGroupDataSource, loadBalancer.NewLoadBalancerDataSource, logMeInstance.NewInstanceDataSource, logMeCredential.NewCredentialDataSource, @@ -444,6 +452,10 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasNetwork.NewNetworkResource, iaasNetworkArea.NewNetworkAreaResource, iaasNetworkAreaRoute.NewNetworkAreaRouteResource, + iaasNetworkInterface.NewNetworkInterfaceResource, + iaasVolume.NewVolumeResource, + iaasServer.NewServerResource, + iaasSecurityGroup.NewSecurityGroupResource, loadBalancer.NewLoadBalancerResource, loadBalancerCredential.NewCredentialResource, loadBalancerObservabilityCredential.NewObservabilityCredentialResource,