IaaS Release (#543)

* IaaS Volume (#541)

* Onboard IaaS Volume

* Labels mapping

* Add acceptance test

* Remove source field

* Fix lint

* Add examples and docs

* Fix lint

* Fix lint

* Fix lint

* Volume source field (#542)

* Onboard IaaS Volume

* Labels mapping

* Add acceptance test

* Remove source field

* Fix lint

* Add examples and docs

* Fix lint

* Fix lint

* Fix lint

* Add source field supoort

* Fix labels and source mapping

* Remove unecessary source mapping

* Move methods to conversion pkg

* Revert change

* Update stackit/internal/services/iaas/volume/datasource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/volume/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/volume/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Update stackit/internal/services/iaas/volume/resource.go

Co-authored-by: João Palet <joao.palet@outlook.com>

* Changes after review

* Change after revie

---------

Co-authored-by: João Palet <joao.palet@outlook.com>

* Onboard IaaS security groups (#545)

* onboard iaas security group

* add examples and generate docs

* fix linter issues

* fix deletion

* Update stackit/internal/services/iaas/securitygroup/resource.go

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* rename data source example file

* update docs

* remove field

* remove field

* remove plan modifier from the name field

* refactor labels in mapFields

* change function from utils to conversion

* remove rules from the security group

* update docs

* add security group acceptance test

* add plan modifiers to stateful field

* sort imports

* change stateful description

---------

Co-authored-by: Gökçe Gök Klingel <goekce.goek_klingel@stackit.cloud>
Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* IaaS Server baseline configuration (#546)

* Server resource schema

* Implemente CRUD methods and unit testsg

* Bug fixes

* Bug fix

* Make variable private

* Remove delete_on_termination and update descriptions

* Add security_group field to initial networking

* Add examples and acc test

* Generate docs

* Fix lint

* Fix lint issue

* Fix unit test

* Update desc

* Gen docs

* Onboard IaaS network interface (#544)

* implement network interface

* handle labels

* add CIDR validation

* fix linter issues and generate docs

* remove computed from the allowed addresses and fix the conditions

* Update stackit/internal/services/iaas/networkinterface/resource.go

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* Update stackit/internal/services/iaas/networkinterface/datasource.go

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* apply code review changes

* remove status from schema

* remove unnecessary GET call

* Update stackit/internal/services/iaas/networkinterface/resource.go

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* Update stackit/internal/services/iaas/networkinterface/resource.go

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* rename nic_security to security

* add beta markdown description

* use existing validateIP function

* use utils function for the options listing

* refactor labels

* change function from utils to conversion

* make allowed addresses a list of strings

* add acceptance test for network interfaces

* fix acceptance test

* rename security_groups as security_group_ids

* extend descriptions

* fix acc test

---------

Co-authored-by: Gökçe Gök Klingel <goekce.goek_klingel@stackit.cloud>
Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* rename volume data source example (#552)

Co-authored-by: Gökçe Gök Klingel <goekce.goek_klingel@stackit.cloud>

* add requires replace to ipv4 and ipv6 fields (#549)

Co-authored-by: Gökçe Gök Klingel <goekce.goek_klingel@stackit.cloud>
Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* Server resource improvements (#548)

* Improvements to server resource

* Fix example

* Remove useStateForUnknown

* Update SDK modules

* Update iaasalpha moduel (#555)

* Remove initial networking field (#556)

* Server attachment resources (#557)

* Server attachemnt resources

* Add examples

* Update volume datasource example

* Fix linting issues

* Fix linting

* Fix examples formatting

* Update go.mod

* Revert iaas to v0.11

* Onboard iaas public ip (#551)

* onboard public ip

* onboard public ip

* add public ip acceptance test

* Update examples/data-sources/stackit_public_ip/data-source.tf

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* add plan modifier to IP

* change type in the volume data source

* add network_interface field to public ip resource

* rename network_interface to network_interface_id

* remove obsolete checks

* extend unit tests

* add network_interface_id in example

* extend unit test

* extend acceptance test

* sort imports

---------

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* Add labels to network, network are and network area route resources (#559)

* Fix network_interface example

* Extend network, network area and network area route with labels

* Revert iaas to v0.11.0

---------

Co-authored-by: GokceGK <161626272+GokceGK@users.noreply.github.com>

* Onboard iaas security group rule (#553)

* onboard security group rule

* add security group rule to acceptance test

* change type in examples

* fix acc test issues

* extend example with objects

* remove obsolete field from acceptance test

* remove unnecessary plan modifier

* adapt schema fields

* adapt schema fields

* add requires replace to all fields

* extend descriptions with protocol limitations

* rename subfield protocol to number

* add requires replace to objects

* make icmp_parameters fields required

* add empty field checks for nested objects

* make max and min fields required in the port_range object

* make number field computed in the protocol object

* add UseStateForUnknown in protocol number

* remove obsolete unit test

* add checks for empty protocol and adapt unit test

* add atLeastOneOf validation in protocol fields

* fix linter issues

* Add project existence check before deleting SNA (#561)

* add project list check and error in network area deletion

* Update stackit/internal/services/iaas/networkarea/resource.go

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

---------

Co-authored-by: Vicente Pinto <vicente.pinto@freiheit.com>

* Example server use cases and other fixes (#560)

* Add example usage to server resource

* Update examples

* Fix beta warning

* Update docs and examples

* Remove size from example

* Fix server description, fix security group rule error message

* Other fixes

* remove field from datasource

---------

Co-authored-by: GokceGK <161626272+GokceGK@users.noreply.github.com>

* Security group rule fixes (#562)

* Add example usage to server resource

* Update examples

* Fix beta warning

* Update docs and examples

* Remove size from example

* Fix server description, fix security group rule error message

* Other fixes

* Fixes to sec group rule

* Fix lint

* Change after review

---------

Co-authored-by: GokceGK <161626272+GokceGK@users.noreply.github.com>

* Fix server example (#565)

* Fix server example

* Fixes to examples, add CIDR validation to nic

* Migrate iaasalpha to iaas (#568)

* Migrate iaasalpha to iaas

* Fix lint

* Update example

* Improvements to security group rule (#569)

* Improvements to security group rule

* Fix lint

* Fix example and remove computed from description

* Fix formatting

* Update description

---------

Co-authored-by: João Palet <joao.palet@outlook.com>
Co-authored-by: GokceGK <161626272+GokceGK@users.noreply.github.com>
Co-authored-by: Gökçe Gök Klingel <goekce.goek_klingel@stackit.cloud>
This commit is contained in:
Vicente Pinto 2024-10-18 16:37:41 +01:00 committed by GitHub
parent 89dbf777fc
commit 93fe2fe89f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 10148 additions and 161 deletions

View file

@ -31,6 +31,7 @@ data "stackit_network" "example" {
- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`network_id`".
- `ipv4_prefix_length` (Number) The IPv4 prefix length of the network.
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
- `name` (String) The name of the network.
- `nameservers` (List of String) The nameservers of the network.
- `prefixes` (List of String) The prefixes of the network.

View file

@ -35,6 +35,7 @@ data "stackit_network_area" "example" {
- `default_nameservers` (List of String) List of DNS Servers/Nameservers.
- `default_prefix_length` (Number) The default prefix length for networks in the network area.
- `id` (String) Terraform's internal resource ID. It is structured as "`organization_id`,`network_area_id`".
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
- `max_prefix_length` (Number) The maximal prefix length for networks in the network area.
- `min_prefix_length` (Number) The minimal prefix length for networks in the network area.
- `name` (String) The name of the network area.

View file

@ -35,5 +35,6 @@ data "stackit_network_area_route" "example" {
### Read-Only
- `id` (String) Terraform's internal data source ID. It is structured as "`organization_id`,`network_area_id`,`network_area_route_id`".
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
- `next_hop` (String) The IP address of the routing system, that will route the prefix configured. Should be a valid IPv4 address.
- `prefix` (String) The network, that is reachable though the Next Hop. Should use CIDR notation.

View file

@ -0,0 +1,46 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_network_interface Data Source - stackit"
subcategory: ""
description: |-
Network interface datasource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_network_interface (Data Source)
Network interface datasource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
data "stackit_network_interface" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `network_id` (String) The network ID to which the network interface is associated.
- `network_interface_id` (String) The network interface ID.
- `project_id` (String) STACKIT project ID to which the network interface is associated.
### Read-Only
- `allowed_addresses` (List of String) The list of CIDR (Classless Inter-Domain Routing) notations.
- `device` (String) The device UUID of the network interface.
- `id` (String) Terraform's internal data source ID. It is structured as "`project_id`,`network_id`,`network_interface_id`".
- `ipv4` (String) The IPv4 address.
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a network interface.
- `mac` (String) The MAC address of network interface.
- `name` (String) The name of the network interface.
- `security` (Boolean) The Network Interface Security. If set to false, then no security groups will apply to this network interface.
- `security_group_ids` (List of String) The list of security group UUIDs. If security is set to false, setting this field will lead to an error.
- `type` (String) Type of network interface. Some of the possible values are: Supported values are: `server`, `metadata`, `gateway`.

View file

@ -0,0 +1,38 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_public_ip Data Source - stackit"
subcategory: ""
description: |-
Volume resource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_public_ip (Data Source)
Volume resource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
data "stackit_public_ip" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
public_ip_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `project_id` (String) STACKIT project ID to which the public IP is associated.
- `public_ip_id` (String) The public IP ID.
### Read-Only
- `id` (String) Terraform's internal datasource ID. It is structured as "`project_id`,`public_ip_id`".
- `ip` (String) The IP address.
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
- `network_interface_id` (String) Associates the public IP with a network interface or a virtual IP (ID).

View file

@ -0,0 +1,39 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_security_group Data Source - stackit"
subcategory: ""
description: |-
Security group datasource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_security_group (Data Source)
Security group datasource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
data "stackit_security_group" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
```
<!-- schema generated by tfplugindocs -->
## 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.

View file

@ -0,0 +1,71 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_security_group_rule Data Source - stackit"
subcategory: ""
description: |-
Security group datasource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_security_group_rule (Data Source)
Security group datasource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
data "stackit_security_group_rule" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_rule_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `project_id` (String) STACKIT project ID to which the security group rule is associated.
- `security_group_id` (String) The security group ID.
- `security_group_rule_id` (String) The security group rule ID.
### Read-Only
- `description` (String) The description of the security group rule.
- `direction` (String) The direction of the traffic which the rule should match. Some of the possible values are: Supported values are: `ingress`, `egress`.
- `ether_type` (String) The ethertype which the rule should match.
- `icmp_parameters` (Attributes) ICMP Parameters. (see [below for nested schema](#nestedatt--icmp_parameters))
- `id` (String) Terraform's internal datasource ID. It is structured as "`project_id`,`security_group_id`,`security_group_rule_id`".
- `ip_range` (String) The remote IP range which the rule should match.
- `port_range` (Attributes) The range of ports. (see [below for nested schema](#nestedatt--port_range))
- `protocol` (Attributes) The internet protocol which the rule should match. (see [below for nested schema](#nestedatt--protocol))
- `remote_security_group_id` (String) The remote security group which the rule should match.
<a id="nestedatt--icmp_parameters"></a>
### Nested Schema for `icmp_parameters`
Read-Only:
- `code` (Number) ICMP code. Can be set if the protocol is ICMP.
- `type` (Number) ICMP type. Can be set if the protocol is ICMP.
<a id="nestedatt--port_range"></a>
### Nested Schema for `port_range`
Read-Only:
- `max` (Number) The maximum port number. Should be greater or equal to the minimum.
- `min` (Number) The minimum port number. Should be less or equal to the minimum.
<a id="nestedatt--protocol"></a>
### Nested Schema for `protocol`
Read-Only:
- `name` (String) The protocol name which the rule should match.
- `number` (Number) The protocol number which the rule should match.

View file

@ -0,0 +1,50 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_server Data Source - stackit"
subcategory: ""
description: |-
Server datasource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_server (Data Source)
Server datasource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `project_id` (String) STACKIT project ID to which the server is associated.
- `server_id` (String) The server ID.
### Read-Only
- `affinity_group` (String) The affinity group the server is assigned to.
- `availability_zone` (String) The availability zone of the server.
- `boot_volume` (Attributes) The boot volume for the server (see [below for nested schema](#nestedatt--boot_volume))
- `created_at` (String) Date-time when the server was created
- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`".
- `image_id` (String) The image ID to be used for an ephemeral disk on the server.
- `keypair_name` (String) The name of the keypair used during server creation.
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
- `launched_at` (String) Date-time when the server was launched
- `machine_type` (String) Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)
- `name` (String) The name of the server.
- `updated_at` (String) Date-time when the server was updated
- `user_data` (String) User data that is passed via cloud-init to the server.
<a id="nestedatt--boot_volume"></a>
### 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`.

View file

@ -0,0 +1,51 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_volume Data Source - stackit"
subcategory: ""
description: |-
Volume resource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_volume (Data Source)
Volume resource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
data "stackit_volume" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
```
<!-- schema generated by tfplugindocs -->
## 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))
<a id="nestedatt--source"></a>
### 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`.

View file

@ -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.
---

View file

@ -18,6 +18,9 @@ resource "stackit_network" "example" {
name = "example-network"
nameservers = ["1.2.3.4", "5.6.7.8"]
ipv4_prefix_length = 24
labels = {
"key" = "value"
}
}
```
@ -32,6 +35,7 @@ resource "stackit_network" "example" {
### Optional
- `ipv4_prefix_length` (Number) The IPv4 prefix length of the network.
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
- `nameservers` (List of String) The nameservers of the network.
### Read-Only

View file

@ -4,12 +4,15 @@ page_title: "stackit_network_area Resource - stackit"
subcategory: ""
description: |-
Network area resource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_network_area (Resource)
Network area resource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
@ -18,10 +21,13 @@ resource "stackit_network_area" "example" {
name = "example-network-area"
network_ranges = [
{
prefix = "1.2.3.4"
prefix = "192.168.0.0/24"
}
]
transfer_network = "1.2.3.4/5"
transfer_network = "192.168.0.0/24"
labels = {
"key" = "value"
}
}
```
@ -39,14 +45,13 @@ resource "stackit_network_area" "example" {
- `default_nameservers` (List of String) List of DNS Servers/Nameservers.
- `default_prefix_length` (Number) The default prefix length for networks in the network area.
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
- `max_prefix_length` (Number) The maximal prefix length for networks in the network area.
- `min_prefix_length` (Number) The minimal prefix length for networks in the network area.
### Read-Only
- `id` (String) Network area resource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
- `id` (String) Terraform's internal resource ID. It is structured as "`organization_id`,`network_area_id`".
- `network_area_id` (String) The network area ID.
- `project_count` (Number) The amount of projects currently referencing this area.

View file

@ -4,20 +4,26 @@ page_title: "stackit_network_area_route Resource - stackit"
subcategory: ""
description: |-
Network area route resource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_network_area_route (Resource)
Network area route resource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
resource "stackit_network_area" "example" {
resource "stackit_network_area_route" "example" {
organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_area_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
prefix = "1.2.3.4/5"
next_hop = "6.7.8.9"
prefix = "192.168.0.0/24"
next_hop = "192.168.0.0"
labels = {
"key" = "value"
}
}
```
@ -31,9 +37,11 @@ resource "stackit_network_area" "example" {
- `organization_id` (String) STACKIT organization ID to which the network area is associated.
- `prefix` (String) The network, that is reachable though the Next Hop. Should use CIDR notation.
### Optional
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
### Read-Only
- `id` (String) Network area route resource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
- `id` (String) Terraform's internal resource ID. It is structured as "`organization_id`,`network_area_id`,`network_area_route_id`".
- `network_area_route_id` (String) The network area route ID.

View file

@ -0,0 +1,50 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_network_interface Resource - stackit"
subcategory: ""
description: |-
Network interface resource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_network_interface (Resource)
Network interface resource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
resource "stackit_network_interface" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
allowed_addresses = ["192.168.0.0/24"]
security_group_ids = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"]
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `network_id` (String) The network ID to which the network interface is associated.
- `project_id` (String) STACKIT project ID to which the network is associated.
### Optional
- `allowed_addresses` (List of String) The list of CIDR (Classless Inter-Domain Routing) notations.
- `ipv4` (String) The IPv4 address.
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a network interface.
- `name` (String) The name of the network interface.
- `security` (Boolean) The Network Interface Security. If set to false, then no security groups will apply to this network interface.
- `security_group_ids` (List of String) The list of security group UUIDs. If security is set to false, setting this field will lead to an error.
### Read-Only
- `device` (String) The device UUID of the network interface.
- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`network_id`,`network_interface_id`".
- `mac` (String) The MAC address of network interface.
- `network_interface_id` (String) The network interface ID.
- `type` (String) Type of network interface. Some of the possible values are: Supported values are: `server`, `metadata`, `gateway`.

View file

@ -0,0 +1,44 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_public_ip Resource - stackit"
subcategory: ""
description: |-
Public IP resource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_public_ip (Resource)
Public IP resource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
resource "stackit_public_ip" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
labels = {
"key" = "value"
}
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `project_id` (String) STACKIT project ID to which the public IP is associated.
### Optional
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
- `network_interface_id` (String) Associates the public IP with a network interface or a virtual IP (ID).
### Read-Only
- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`public_ip_id`".
- `ip` (String) The IP address.
- `public_ip_id` (String) The public IP ID.

View file

@ -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 generated by tfplugindocs -->
## 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.

View file

@ -0,0 +1,81 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_security_group_rule Resource - stackit"
subcategory: ""
description: |-
Security group rule resource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_security_group_rule (Resource)
Security group rule resource schema. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
resource "stackit_security_group_rule" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
direction = "ingress"
icmp_parameters = {
code = 0
type = 8
}
protocol = {
name = "icmp"
}
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `direction` (String) The direction of the traffic which the rule should match. Some of the possible values are: Supported values are: `ingress`, `egress`.
- `project_id` (String) STACKIT project ID to which the security group rule is associated.
- `security_group_id` (String) The security group ID.
### Optional
- `description` (String) The rule description.
- `ether_type` (String) The ethertype which the rule should match.
- `icmp_parameters` (Attributes) ICMP Parameters. These parameters should only be provided if the protocol is ICMP. (see [below for nested schema](#nestedatt--icmp_parameters))
- `ip_range` (String) The remote IP range which the rule should match.
- `port_range` (Attributes) The range of ports. This should only be provided if the protocol is not ICMP. (see [below for nested schema](#nestedatt--port_range))
- `protocol` (Attributes) The internet protocol which the rule should match. (see [below for nested schema](#nestedatt--protocol))
- `remote_security_group_id` (String) The remote security group which the rule should match.
### Read-Only
- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`security_group_id`,`security_group_rule_id`".
- `security_group_rule_id` (String) The security group rule ID.
<a id="nestedatt--icmp_parameters"></a>
### Nested Schema for `icmp_parameters`
Required:
- `code` (Number) ICMP code. Can be set if the protocol is ICMP.
- `type` (Number) ICMP type. Can be set if the protocol is ICMP.
<a id="nestedatt--port_range"></a>
### Nested Schema for `port_range`
Required:
- `max` (Number) The maximum port number. Should be greater or equal to the minimum.
- `min` (Number) The minimum port number. Should be less or equal to the maximum.
<a id="nestedatt--protocol"></a>
### Nested Schema for `protocol`
Optional:
- `name` (String) The protocol name which the rule should match. Either `name` or `number` must be provided.
- `number` (Number) The protocol number which the rule should match. Either `name` or `number` must be provided.

374
docs/resources/server.md Normal file
View file

@ -0,0 +1,374 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_server Resource - stackit"
subcategory: ""
description: |-
Server resource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
Example Usage
Boot from volume
resource "stackit_server" "boot-from-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
availability_zone = "eu01-1"
machine_type = "g1.1"
keypair_name = "example-keypair"
}
Boot from existing volume
resource "stackit_volume" "example-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 12
source = {
type = "image"
id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-volume"
availability_zone = "eu01-1"
}
resource "stackit_server" "boot-from-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
source_type = "volume"
source_id = stackit_volume.example-volume.volume_id
}
availability_zone = "eu01-1"
machine_type = "g1.1"
keypair_name = "example-keypair"
}
Network setup
resource "stackit_server" "server-with-network" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
machine_type = "g1.1"
keypair_name = "example-keypair"
}
resource "stackit_network" "network" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-network"
nameservers = ["192.0.2.0", "198.51.100.0", "203.0.113.0"]
ipv4_prefix_length = 24
}
resource "stackit_security_group" "sec-group" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-security-group"
stateful = true
}
resource "stackit_security_group_rule" "rule" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = stackit_security_group.sec-group.security_group_id
direction = "ingress"
ether_type = "IPv4"
}
resource "stackit_network_interface" "nic" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_id = stackit_network.network.network_id
security_group_ids = [stackit_security_group.sec-group.security_group_id]
}
resource "stackit_public_ip" "public-ip" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_interface_id = stackit_network_interface.nic.network_interface_id
}
resource "stackit_server_network_interface_attach" "nic-attachment" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = stackit_server.server-with-network.server_id
network_interface_id = stackit_network_interface.nic.network_interface_id
}
Server with attached volume
resource "stackit_volume" "example-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 12
source = {
type = "image"
id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-volume"
availability_zone = "eu01-1"
}
resource "stackit_server" "server-with-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
availability_zone = "eu01-1"
machine_type = "g1.1"
keypair_name = "example-keypair"
}
resource "stackit_server_volume_attach" "attach_volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = stackit_server.server-with-volume.server_id
volume_id = stackit_volume.example-volume.volume_id
}
Server with user data (cloud-init)
resource "stackit_server" "user-data" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-server"
machine_type = "g1.1"
keypair_name = "example-keypair"
user_data = "#!/bin/bash\n/bin/su"
}
resource "stackit_server" "user-data-from-file" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-server"
machine_type = "g1.1"
keypair_name = "example-keypair"
user_data = file("${path.module}/cloud-init.yaml")
}
---
# stackit_server (Resource)
Server resource schema. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
### Boot from volume
```terraform
resource "stackit_server" "boot-from-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
availability_zone = "eu01-1"
machine_type = "g1.1"
keypair_name = "example-keypair"
}
```
### Boot from existing volume
```terraform
resource "stackit_volume" "example-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 12
source = {
type = "image"
id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-volume"
availability_zone = "eu01-1"
}
resource "stackit_server" "boot-from-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
source_type = "volume"
source_id = stackit_volume.example-volume.volume_id
}
availability_zone = "eu01-1"
machine_type = "g1.1"
keypair_name = "example-keypair"
}
```
### Network setup
```terraform
resource "stackit_server" "server-with-network" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
machine_type = "g1.1"
keypair_name = "example-keypair"
}
resource "stackit_network" "network" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-network"
nameservers = ["192.0.2.0", "198.51.100.0", "203.0.113.0"]
ipv4_prefix_length = 24
}
resource "stackit_security_group" "sec-group" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-security-group"
stateful = true
}
resource "stackit_security_group_rule" "rule" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = stackit_security_group.sec-group.security_group_id
direction = "ingress"
ether_type = "IPv4"
}
resource "stackit_network_interface" "nic" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_id = stackit_network.network.network_id
security_group_ids = [stackit_security_group.sec-group.security_group_id]
}
resource "stackit_public_ip" "public-ip" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_interface_id = stackit_network_interface.nic.network_interface_id
}
resource "stackit_server_network_interface_attach" "nic-attachment" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = stackit_server.server-with-network.server_id
network_interface_id = stackit_network_interface.nic.network_interface_id
}
```
### Server with attached volume
```terraform
resource "stackit_volume" "example-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 12
source = {
type = "image"
id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-volume"
availability_zone = "eu01-1"
}
resource "stackit_server" "server-with-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
availability_zone = "eu01-1"
machine_type = "g1.1"
keypair_name = "example-keypair"
}
resource "stackit_server_volume_attach" "attach_volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = stackit_server.server-with-volume.server_id
volume_id = stackit_volume.example-volume.volume_id
}
```
### Server with user data (cloud-init)
```terraform
resource "stackit_server" "user-data" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-server"
machine_type = "g1.1"
keypair_name = "example-keypair"
user_data = "#!/bin/bash\n/bin/su"
}
resource "stackit_server" "user-data-from-file" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-server"
machine_type = "g1.1"
keypair_name = "example-keypair"
user_data = file("${path.module}/cloud-init.yaml")
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `machine_type` (String) Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)
- `name` (String) The name of the server.
- `project_id` (String) STACKIT project ID to which the server is associated.
### Optional
- `affinity_group` (String) The affinity group the server is assigned to.
- `availability_zone` (String) The availability zone of the server.
- `boot_volume` (Attributes) The boot volume for the server (see [below for nested schema](#nestedatt--boot_volume))
- `image_id` (String) The image ID to be used for an ephemeral disk on the server.
- `keypair_name` (String) The name of the keypair used during server creation.
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container
- `user_data` (String) User data that is passed via cloud-init to the server.
### Read-Only
- `created_at` (String) Date-time when the server was created
- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`".
- `launched_at` (String) Date-time when the server was launched
- `server_id` (String) The server ID.
- `updated_at` (String) Date-time when the server was updated
<a id="nestedatt--boot_volume"></a>
### 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`.

View file

@ -0,0 +1,37 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_server_network_interface_attach Resource - stackit"
subcategory: ""
description: |-
Network interface attachment resource schema. Attaches a network interface to a server. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_server_network_interface_attach (Resource)
Network interface attachment resource schema. Attaches a network interface to a server. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
resource "stackit_server_network_interface_attach" "attached_network_interface" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `network_interface_id` (String) The network interface ID.
- `project_id` (String) STACKIT project ID to which the network interface attachment is associated.
- `server_id` (String) The server ID.
### Read-Only
- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`,`network_interface_id`".

View file

@ -0,0 +1,37 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_server_service_account_attach Resource - stackit"
subcategory: ""
description: |-
Service account attachment resource schema. Attaches a service account to a server. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_server_service_account_attach (Resource)
Service account attachment resource schema. Attaches a service account to a server. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
resource "stackit_server_service_account_attach" "attached_service_account" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
service_account_email = "service-account@stackit.cloud"
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `project_id` (String) STACKIT project ID to which the service account attachment is associated.
- `server_id` (String) The server ID.
- `service_account_email` (String) The service account email.
### Read-Only
- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`,`service_account_email`".

View file

@ -0,0 +1,37 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "stackit_server_volume_attach Resource - stackit"
subcategory: ""
description: |-
Volume attachment resource schema. Attaches a volume to a server. Must have a region specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources.
---
# stackit_server_volume_attach (Resource)
Volume attachment resource schema. Attaches a volume to a server. Must have a `region` specified in the provider configuration.
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage
```terraform
resource "stackit_server_volume_attach" "attached_volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
```
<!-- schema generated by tfplugindocs -->
## Schema
### Required
- `project_id` (String) STACKIT project ID to which the volume attachment is associated.
- `server_id` (String) The server ID.
- `volume_id` (String) The volume ID.
### Read-Only
- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`,`volume_id`".

View file

@ -3,12 +3,12 @@
page_title: "stackit_sqlserverflex_user Resource - stackit"
subcategory: ""
description: |-
[Warning: BETA] SQLServer Flex user resource schema. Must have a region specified in the provider configuration.
SQLServer Flex user resource schema. Must have a region specified in the provider configuration.
---
# stackit_sqlserverflex_user (Resource)
[Warning: BETA] SQLServer Flex user resource schema. Must have a `region` specified in the provider configuration.
SQLServer Flex user resource schema. Must have a `region` specified in the provider configuration.
## Example Usage

59
docs/resources/volume.md Normal file
View file

@ -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 generated by tfplugindocs -->
## 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.
<a id="nestedatt--source"></a>
### 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`.

View file

@ -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"
}

View file

@ -0,0 +1,4 @@
data "stackit_public_ip" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
public_ip_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

View file

@ -0,0 +1,4 @@
data "stackit_security_group" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

View file

@ -0,0 +1,5 @@
data "stackit_security_group_rule" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_rule_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

View file

@ -0,0 +1,4 @@
data "stackit_server" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

View file

@ -0,0 +1,4 @@
data "stackit_volume" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

View file

@ -3,4 +3,7 @@ resource "stackit_network" "example" {
name = "example-network"
nameservers = ["1.2.3.4", "5.6.7.8"]
ipv4_prefix_length = 24
labels = {
"key" = "value"
}
}

View file

@ -3,8 +3,11 @@ resource "stackit_network_area" "example" {
name = "example-network-area"
network_ranges = [
{
prefix = "1.2.3.4"
prefix = "192.168.0.0/24"
}
]
transfer_network = "1.2.3.4/5"
transfer_network = "192.168.0.0/24"
labels = {
"key" = "value"
}
}

View file

@ -1,6 +1,9 @@
resource "stackit_network_area" "example" {
resource "stackit_network_area_route" "example" {
organization_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_area_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
prefix = "1.2.3.4/5"
next_hop = "6.7.8.9"
prefix = "192.168.0.0/24"
next_hop = "192.168.0.0"
labels = {
"key" = "value"
}
}

View file

@ -0,0 +1,6 @@
resource "stackit_network_interface" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
allowed_addresses = ["192.168.0.0/24"]
security_group_ids = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"]
}

View file

@ -0,0 +1,7 @@
resource "stackit_public_ip" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
labels = {
"key" = "value"
}
}

View file

@ -0,0 +1,7 @@
resource "stackit_security_group" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "my_security_group"
labels = {
"key" = "value"
}
}

View file

@ -0,0 +1,12 @@
resource "stackit_security_group_rule" "example" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
direction = "ingress"
icmp_parameters = {
code = 0
type = 8
}
protocol = {
name = "icmp"
}
}

View file

@ -0,0 +1,5 @@
resource "stackit_server_network_interface_attach" "attached_network_interface" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

View file

@ -0,0 +1,5 @@
resource "stackit_server_service_account_attach" "attached_service_account" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
service_account_email = "service-account@stackit.cloud"
}

View file

@ -0,0 +1,5 @@
resource "stackit_server_volume_attach" "attached_volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

View file

@ -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"
}
}

2
go.mod
View file

@ -14,7 +14,7 @@ require (
github.com/stackitcloud/stackit-sdk-go/core v0.14.0
github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0
github.com/stackitcloud/stackit-sdk-go/services/dns v0.11.0
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.12.0
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.14.0
github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0
github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.0
github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.20.0

4
go.sum
View file

@ -155,8 +155,8 @@ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.4.0 h1:WXSIE4Kf
github.com/stackitcloud/stackit-sdk-go/services/authorization v0.4.0/go.mod h1:8spVqlPqZrvQQ63Qodbydk3qsZx7lr963ECft+sqFhY=
github.com/stackitcloud/stackit-sdk-go/services/dns v0.11.0 h1:+OZ82DwFy4JIJThadVjvll5kUWjHPSLbUIF65njsNBk=
github.com/stackitcloud/stackit-sdk-go/services/dns v0.11.0/go.mod h1:mv8U7kuclXo+0VpDHtBCkve/3i9h1yT+RAId/MUi+C8=
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.12.0 h1:tk/ztsZoFgcsyEFODaej8IGGuvprVbsrrJKGzm2PR14=
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.12.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM=
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.14.0 h1:2CLlZInB7ytcnIpPUhFSbCBrNp0mMhxIuGOxlz3rhoA=
github.com/stackitcloud/stackit-sdk-go/services/iaas v0.14.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM=
github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 h1:06CGP64CEk3Zg6i9kZCMRdmCzLLiyMWQqGK1teBr9Oc=
github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0/go.mod h1:JL94zc8K0ebWs+DBGXR28vNCF0EFV54ZLUtrlXOvWgA=
github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.0 h1:V0UGP7JEa4Q8SsZFUJsKgLGaoPruLn2KVKnqQtaoWCU=

View file

@ -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
}

View file

@ -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)
}
}
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -124,6 +124,11 @@ func (d *networkDataSource) Schema(_ context.Context, _ datasource.SchemaRequest
Description: "The public IP of the network.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
},
}
}

View file

@ -7,6 +7,7 @@ import (
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@ -42,6 +43,7 @@ type Model struct {
IPv4PrefixLength types.Int64 `tfsdk:"ipv4_prefix_length"`
Prefixes types.List `tfsdk:"prefixes"`
PublicIP types.String `tfsdk:"public_ip"`
Labels types.Map `tfsdk:"labels"`
}
// NewNetworkResource is a helper function to simplify the provider implementation.
@ -160,6 +162,11 @@ func (r *networkResource) Schema(_ context.Context, _ resource.SchemaRequest, re
Description: "The public IP of the network.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
},
}
}
@ -178,7 +185,7 @@ func (r *networkResource) Create(ctx context.Context, req resource.CreateRequest
ctx = tflog.SetField(ctx, "project_id", projectId)
// Generate API request body from model
payload, err := toCreatePayload(&model)
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network", fmt.Sprintf("Creating API payload: %v", err))
return
@ -269,8 +276,16 @@ func (r *networkResource) Update(ctx context.Context, req resource.UpdateRequest
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(&model)
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network", fmt.Sprintf("Creating API payload: %v", err))
return
@ -378,6 +393,20 @@ func mapFields(ctx context.Context, networkResp *iaas.Network, model *Model) err
strings.Join(idParts, core.Separator),
)
labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{})
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
if networkResp.Labels != nil && len(*networkResp.Labels) != 0 {
var diags diag.Diagnostics
labels, diags = types.MapValueFrom(ctx, types.StringType, *networkResp.Labels)
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
} else if model.Labels.IsNull() {
labels = types.MapNull(types.StringType)
}
if networkResp.Nameservers == nil {
model.Nameservers = types.ListNull(types.StringType)
} else {
@ -412,11 +441,12 @@ func mapFields(ctx context.Context, networkResp *iaas.Network, model *Model) err
model.NetworkId = types.StringValue(networkId)
model.Name = types.StringPointerValue(networkResp.Name)
model.PublicIP = types.StringPointerValue(networkResp.PublicIp)
model.Labels = labels
return nil
}
func toCreatePayload(model *Model) (*iaas.CreateNetworkPayload, error) {
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
@ -430,6 +460,11 @@ func toCreatePayload(model *Model) (*iaas.CreateNetworkPayload, error) {
modelNameservers = append(modelNameservers, nameserverString.ValueString())
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.CreateNetworkPayload{
Name: conversion.StringValueToPointer(model.Name),
AddressFamily: &iaas.CreateNetworkAddressFamily{
@ -438,10 +473,11 @@ func toCreatePayload(model *Model) (*iaas.CreateNetworkPayload, error) {
Nameservers: &modelNameservers,
},
},
Labels: &labels,
}, nil
}
func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkPayload, error) {
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.PartialUpdateNetworkPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
@ -455,6 +491,11 @@ func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkPayload, error) {
modelNameservers = append(modelNameservers, nameserverString.ValueString())
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.PartialUpdateNetworkPayload{
Name: conversion.StringValueToPointer(model.Name),
AddressFamily: &iaas.UpdateNetworkAddressFamily{
@ -462,5 +503,6 @@ func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkPayload, error) {
Nameservers: &modelNameservers,
},
},
Labels: &labels,
}, nil
}

View file

@ -37,6 +37,7 @@ func TestMapFields(t *testing.T) {
IPv4PrefixLength: types.Int64Null(),
Prefixes: types.ListNull(types.StringType),
PublicIP: types.StringNull(),
Labels: types.MapNull(types.StringType),
},
true,
},
@ -58,6 +59,9 @@ func TestMapFields(t *testing.T) {
"prefix2",
},
PublicIp: utils.Ptr("publicIp"),
Labels: &map[string]interface{}{
"key": "value",
},
},
Model{
Id: types.StringValue("pid,nid"),
@ -74,6 +78,9 @@ func TestMapFields(t *testing.T) {
types.StringValue("prefix2"),
}),
PublicIP: types.StringValue("publicIp"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
true,
},
@ -104,6 +111,7 @@ func TestMapFields(t *testing.T) {
types.StringValue("ns2"),
types.StringValue("ns3"),
}),
Labels: types.MapNull(types.StringType),
},
true,
},
@ -135,6 +143,7 @@ func TestMapFields(t *testing.T) {
types.StringValue("prefix2"),
types.StringValue("prefix3"),
}),
Labels: types.MapNull(types.StringType),
},
true,
},
@ -190,6 +199,9 @@ func TestToCreatePayload(t *testing.T) {
types.StringValue("ns2"),
}),
IPv4PrefixLength: types.Int64Value(24),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.CreateNetworkPayload{
Name: utils.Ptr("name"),
@ -202,13 +214,16 @@ func TestToCreatePayload(t *testing.T) {
PrefixLength: utils.Ptr(int64(24)),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(tt.input)
output, err := toCreatePayload(context.Background(), tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
@ -240,6 +255,9 @@ func TestToUpdatePayload(t *testing.T) {
types.StringValue("ns1"),
types.StringValue("ns2"),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.PartialUpdateNetworkPayload{
Name: utils.Ptr("name"),
@ -251,13 +269,16 @@ func TestToUpdatePayload(t *testing.T) {
},
},
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(tt.input)
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}

View file

@ -185,6 +185,11 @@ func (d *networkAreaDataSource) Schema(_ context.Context, _ datasource.SchemaReq
int64validator.AtMost(29),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
},
}
}

View file

@ -56,6 +56,7 @@ type Model struct {
DefaultPrefixLength types.Int64 `tfsdk:"default_prefix_length"`
MaxPrefixLength types.Int64 `tfsdk:"max_prefix_length"`
MinPrefixLength types.Int64 `tfsdk:"min_prefix_length"`
Labels types.Map `tfsdk:"labels"`
}
// Struct corresponding to Model.NetworkRanges[i]
@ -133,12 +134,12 @@ func (r *networkAreaResource) Configure(ctx context.Context, req resource.Config
// Schema defines the schema for the resource.
func (r *networkAreaResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Network area resource schema. Must have a `region` specified in the provider configuration.",
Description: "Network area resource schema. Must have a `region` specified in the provider configuration.",
MarkdownDescription: features.AddBetaDescription("Network area resource schema. Must have a `region` specified in the provider configuration."),
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`\".",
MarkdownDescription: features.AddBetaDescription("Network area resource schema. Must have a `region` specified in the provider configuration."),
Computed: true,
Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
@ -245,6 +246,11 @@ func (r *networkAreaResource) Schema(_ context.Context, _ resource.SchemaRequest
},
Default: int64default.StaticInt64(24),
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
},
}
}
@ -365,8 +371,16 @@ func (r *networkAreaResource) Update(ctx context.Context, req resource.UpdateReq
}
}
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(&model)
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network area", fmt.Sprintf("Creating API payload: %v", err))
return
@ -431,8 +445,19 @@ func (r *networkAreaResource) Delete(ctx context.Context, req resource.DeleteReq
ctx = tflog.SetField(ctx, "organization_id", organizationId)
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
projects, err := r.client.ListNetworkAreaProjects(ctx, organizationId, networkAreaId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area", fmt.Sprintf("Calling API to get the list of projects: %v", err))
return
}
if projects != nil && len(*projects.Items) > 0 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area", fmt.Sprintln("You still have projects attached to the network area. Please delete or remove them from the network area before deleting the network area."))
return
}
// Delete existing network
err := r.client.DeleteNetworkArea(ctx, organizationId, networkAreaId).Execute()
err = r.client.DeleteNetworkArea(ctx, organizationId, networkAreaId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network area", fmt.Sprintf("Calling API: %v", err))
return
@ -518,9 +543,24 @@ func mapFields(ctx context.Context, networkAreaResp *iaas.NetworkArea, networkAr
return fmt.Errorf("mapping network ranges: %w", err)
}
labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{})
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
if networkAreaResp.Labels != nil && len(*networkAreaResp.Labels) != 0 {
var diags diag.Diagnostics
labels, diags = types.MapValueFrom(ctx, types.StringType, *networkAreaResp.Labels)
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
} else if model.Labels.IsNull() {
labels = types.MapNull(types.StringType)
}
model.NetworkAreaId = types.StringValue(networkAreaId)
model.Name = types.StringPointerValue(networkAreaResp.Name)
model.ProjectCount = types.Int64PointerValue(networkAreaResp.ProjectCount)
model.Labels = labels
if networkAreaResp.Ipv4 != nil {
model.TransferNetwork = types.StringPointerValue(networkAreaResp.Ipv4.TransferNetwork)
@ -616,6 +656,11 @@ func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkArea
return nil, fmt.Errorf("converting network ranges: %w", err)
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.CreateNetworkAreaPayload{
Name: conversion.StringValueToPointer(model.Name),
AddressFamily: &iaas.CreateAreaAddressFamily{
@ -628,10 +673,11 @@ func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkArea
MinPrefixLen: conversion.Int64ValueToPointer(model.MinPrefixLength),
},
},
Labels: &labels,
}, nil
}
func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkAreaPayload, error) {
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.PartialUpdateNetworkAreaPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
@ -645,6 +691,11 @@ func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkAreaPayload, error
modelDefaultNameservers = append(modelDefaultNameservers, nameserverString.ValueString())
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.PartialUpdateNetworkAreaPayload{
Name: conversion.StringValueToPointer(model.Name),
AddressFamily: &iaas.UpdateAreaAddressFamily{
@ -655,6 +706,7 @@ func toUpdatePayload(model *Model) (*iaas.PartialUpdateNetworkAreaPayload, error
MinPrefixLen: conversion.Int64ValueToPointer(model.MinPrefixLength),
},
},
Labels: &labels,
}, nil
}

View file

@ -86,6 +86,7 @@ func TestMapFields(t *testing.T) {
"prefix": types.StringValue("prefix-2"),
}),
}),
Labels: types.MapNull(types.StringType),
},
true,
},
@ -118,6 +119,9 @@ func TestMapFields(t *testing.T) {
MinPrefixLen: utils.Ptr(int64(18)),
},
Name: utils.Ptr("name"),
Labels: &map[string]interface{}{
"key": "value",
},
},
&[]iaas.NetworkRange{
{
@ -152,6 +156,9 @@ func TestMapFields(t *testing.T) {
"prefix": types.StringValue("prefix-2"),
}),
}),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
true,
},
@ -226,6 +233,7 @@ func TestMapFields(t *testing.T) {
"prefix": types.StringValue("prefix-3"),
}),
}),
Labels: types.MapNull(types.StringType),
},
true,
},
@ -286,6 +294,7 @@ func TestMapFields(t *testing.T) {
"prefix": types.StringValue("prefix-2"),
}),
}),
Labels: types.MapNull(types.StringType),
},
true,
},
@ -334,6 +343,7 @@ func TestMapFields(t *testing.T) {
"prefix": types.StringValue("prefix-3"),
}),
}),
Labels: types.MapNull(types.StringType),
},
true,
},
@ -412,6 +422,9 @@ func TestToCreatePayload(t *testing.T) {
DefaultPrefixLength: types.Int64Value(20),
MaxPrefixLength: types.Int64Value(22),
MinPrefixLength: types.Int64Value(18),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.CreateNetworkAreaPayload{
Name: utils.Ptr("name"),
@ -435,6 +448,9 @@ func TestToCreatePayload(t *testing.T) {
MinPrefixLen: utils.Ptr(int64(18)),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
@ -476,6 +492,9 @@ func TestToUpdatePayload(t *testing.T) {
DefaultPrefixLength: types.Int64Value(22),
MaxPrefixLength: types.Int64Value(24),
MinPrefixLength: types.Int64Value(20),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.PartialUpdateNetworkAreaPayload{
Name: utils.Ptr("name"),
@ -490,13 +509,16 @@ func TestToUpdatePayload(t *testing.T) {
MinPrefixLen: utils.Ptr(int64(20)),
},
},
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(tt.input)
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}

View file

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
@ -127,6 +128,11 @@ func (d *networkAreaRouteDataSource) Schema(_ context.Context, _ datasource.Sche
Description: "The network, that is reachable though the Next Hop. Should use CIDR notation.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
},
}
}
@ -157,7 +163,7 @@ func (d *networkAreaRouteDataSource) Read(ctx context.Context, req datasource.Re
return
}
err = mapFields(networkAreaRouteResp, &model)
err = mapFields(ctx, networkAreaRouteResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route", fmt.Sprintf("Processing API payload: %v", err))
return

View file

@ -6,9 +6,11 @@ import (
"net/http"
"strings"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
@ -42,6 +44,7 @@ type Model struct {
NetworkAreaRouteId types.String `tfsdk:"network_area_route_id"`
NextHop types.String `tfsdk:"next_hop"`
Prefix types.String `tfsdk:"prefix"`
Labels types.Map `tfsdk:"labels"`
}
// NewNetworkAreaRouteResource is a helper function to simplify the provider implementation.
@ -107,12 +110,12 @@ func (r *networkAreaRouteResource) Configure(ctx context.Context, req resource.C
// Schema defines the schema for the resource.
func (r *networkAreaRouteResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Network area route resource schema. Must have a `region` specified in the provider configuration.",
Description: "Network area route resource schema. Must have a `region` specified in the provider configuration.",
MarkdownDescription: features.AddBetaDescription("Network area route resource schema. Must have a `region` specified in the provider configuration."),
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`,`network_area_route_id`\".",
MarkdownDescription: features.AddBetaDescription("Network area route resource schema. Must have a `region` specified in the provider configuration."),
Computed: true,
Description: "Terraform's internal resource ID. It is structured as \"`organization_id`,`network_area_id`,`network_area_route_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
@ -172,6 +175,14 @@ func (r *networkAreaRouteResource) Schema(_ context.Context, _ resource.SchemaRe
validate.CIDR(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
PlanModifiers: []planmodifier.Map{
mapplanmodifier.RequiresReplace(),
},
},
},
}
}
@ -192,7 +203,7 @@ func (r *networkAreaRouteResource) Create(ctx context.Context, req resource.Crea
ctx = tflog.SetField(ctx, "network_area_id", networkAreaId)
// Generate API request body from model
payload, err := toCreatePayload(&model)
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route", fmt.Sprintf("Creating API payload: %v", err))
return
@ -222,7 +233,7 @@ func (r *networkAreaRouteResource) Create(ctx context.Context, req resource.Crea
ctx = tflog.SetField(ctx, "network_area_route_id", routeId)
// Map response body to schema
err = mapFields(&route, &model)
err = mapFields(ctx, &route, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network area route.", fmt.Sprintf("Processing API payload: %v", err))
return
@ -263,7 +274,7 @@ func (r *networkAreaRouteResource) Read(ctx context.Context, req resource.ReadRe
}
// Map response body to schema
err = mapFields(networkAreaRouteResp, &model)
err = mapFields(ctx, networkAreaRouteResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network area route", fmt.Sprintf("Processing API payload: %v", err))
return
@ -335,7 +346,7 @@ func (r *networkAreaRouteResource) ImportState(ctx context.Context, req resource
tflog.Info(ctx, "Network area route state imported")
}
func mapFields(networkAreaRoute *iaas.Route, model *Model) error {
func mapFields(ctx context.Context, networkAreaRoute *iaas.Route, model *Model) error {
if networkAreaRoute == nil {
return fmt.Errorf("response input is nil")
}
@ -361,22 +372,43 @@ func mapFields(networkAreaRoute *iaas.Route, model *Model) error {
strings.Join(idParts, core.Separator),
)
labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{})
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
if networkAreaRoute.Labels != nil && len(*networkAreaRoute.Labels) != 0 {
var diags diag.Diagnostics
labels, diags = types.MapValueFrom(ctx, types.StringType, *networkAreaRoute.Labels)
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
} else if model.Labels.IsNull() {
labels = types.MapNull(types.StringType)
}
model.NetworkAreaRouteId = types.StringValue(networkAreaRouteId)
model.NextHop = types.StringPointerValue(networkAreaRoute.Nexthop)
model.Prefix = types.StringPointerValue(networkAreaRoute.Prefix)
model.Labels = labels
return nil
}
func toCreatePayload(model *Model) (*iaas.CreateNetworkAreaRoutePayload, error) {
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNetworkAreaRoutePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.CreateNetworkAreaRoutePayload{
Ipv4: &[]iaas.Route{
{
Prefix: conversion.StringValueToPointer(model.Prefix),
Nexthop: conversion.StringValueToPointer(model.NextHop),
Labels: &labels,
},
},
}, nil

View file

@ -1,9 +1,11 @@
package networkarearoute
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
@ -32,6 +34,7 @@ func TestMapFields(t *testing.T) {
NetworkAreaRouteId: types.StringValue("narid"),
Prefix: types.StringNull(),
NextHop: types.StringNull(),
Labels: types.MapNull(types.StringType),
},
true,
},
@ -45,6 +48,9 @@ func TestMapFields(t *testing.T) {
&iaas.Route{
Prefix: utils.Ptr("prefix"),
Nexthop: utils.Ptr("hop"),
Labels: &map[string]interface{}{
"key": "value",
},
},
Model{
Id: types.StringValue("oid,naid,narid"),
@ -53,6 +59,9 @@ func TestMapFields(t *testing.T) {
NetworkAreaRouteId: types.StringValue("narid"),
Prefix: types.StringValue("prefix"),
NextHop: types.StringValue("hop"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
true,
},
@ -86,7 +95,7 @@ func TestMapFields(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(tt.input, &tt.state)
err := mapFields(context.Background(), tt.input, &tt.state)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
@ -115,12 +124,18 @@ func TestToCreatePayload(t *testing.T) {
input: &Model{
Prefix: types.StringValue("prefix"),
NextHop: types.StringValue("hop"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
expected: &iaas.CreateNetworkAreaRoutePayload{
Ipv4: &[]iaas.Route{
{
Prefix: utils.Ptr("prefix"),
Nexthop: utils.Ptr("hop"),
Labels: &map[string]interface{}{
"key": "value",
},
},
},
},
@ -129,7 +144,7 @@ func TestToCreatePayload(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(tt.input)
output, err := toCreatePayload(context.Background(), tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}

View file

@ -0,0 +1,206 @@
package networkinterface
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// scheduleDataSourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var networkInterfaceDataSourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &networkInterfaceDataSource{}
)
// NewNetworkDataSource is a helper function to simplify the provider implementation.
func NewNetworkInterfaceDataSource() datasource.DataSource {
return &networkInterfaceDataSource{}
}
// networkInterfaceDataSource is the data source implementation.
type networkInterfaceDataSource struct {
client *iaas.APIClient
}
// Metadata returns the data source type name.
func (d *networkInterfaceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network_interface"
}
func (d *networkInterfaceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
var apiClient *iaas.APIClient
var err error
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !networkInterfaceDataSourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_network_interface", "data source")
if resp.Diagnostics.HasError() {
return
}
networkInterfaceDataSourceBetaCheckDone = true
}
if providerData.IaaSCustomEndpoint != "" {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err))
return
}
d.client = apiClient
tflog.Info(ctx, "IaaS client configured")
}
// Schema defines the schema for the data source.
func (d *networkInterfaceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
typeOptions := []string{"server", "metadata", "gateway"}
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Network interface datasource schema. Must have a `region` specified in the provider configuration."),
Description: "Network interface datasource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal data source ID. It is structured as \"`project_id`,`network_id`,`network_interface_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the network interface is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_id": schema.StringAttribute{
Description: "The network ID to which the network interface is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_interface_id": schema.StringAttribute{
Description: "The network interface ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the network interface.",
Computed: true,
},
"allowed_addresses": schema.ListAttribute{
Description: "The list of CIDR (Classless Inter-Domain Routing) notations.",
Computed: true,
ElementType: types.StringType,
},
"device": schema.StringAttribute{
Description: "The device UUID of the network interface.",
Computed: true,
},
"ipv4": schema.StringAttribute{
Description: "The IPv4 address.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a network interface.",
ElementType: types.StringType,
Computed: true,
},
"mac": schema.StringAttribute{
Description: "The MAC address of network interface.",
Computed: true,
},
"security": schema.BoolAttribute{
Description: "The Network Interface Security. If set to false, then no security groups will apply to this network interface.",
Computed: true,
},
"security_group_ids": schema.ListAttribute{
Description: "The list of security group UUIDs. If security is set to false, setting this field will lead to an error.",
Computed: true,
ElementType: types.StringType,
},
"type": schema.StringAttribute{
Description: "Type of network interface. Some of the possible values are: " + utils.SupportedValuesDocumentation(typeOptions),
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *networkInterfaceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
networkId := model.NetworkId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
networkInterfaceResp, err := d.client.GetNIC(ctx, projectId, networkId, networkInterfaceId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(ctx, networkInterfaceResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network interface read")
}

View file

@ -0,0 +1,636 @@
package networkinterface
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// resourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var resourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &networkInterfaceResource{}
_ resource.ResourceWithConfigure = &networkInterfaceResource{}
_ resource.ResourceWithImportState = &networkInterfaceResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
NetworkId types.String `tfsdk:"network_id"`
NetworkInterfaceId types.String `tfsdk:"network_interface_id"`
Name types.String `tfsdk:"name"`
AllowedAddresses types.List `tfsdk:"allowed_addresses"`
IPv4 types.String `tfsdk:"ipv4"`
Labels types.Map `tfsdk:"labels"`
Security types.Bool `tfsdk:"security"`
SecurityGroupIds types.List `tfsdk:"security_group_ids"`
Device types.String `tfsdk:"device"`
Mac types.String `tfsdk:"mac"`
Type types.String `tfsdk:"type"`
}
// NewNetworkInterfaceResource is a helper function to simplify the provider implementation.
func NewNetworkInterfaceResource() resource.Resource {
return &networkInterfaceResource{}
}
// networkResource is the resource implementation.
type networkInterfaceResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *networkInterfaceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_network_interface"
}
// Configure adds the provider configured client to the resource.
func (r *networkInterfaceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !resourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_network_interface", "resource")
if resp.Diagnostics.HasError() {
return
}
resourceBetaCheckDone = true
}
var apiClient *iaas.APIClient
var err error
if providerData.IaaSCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint)
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *networkInterfaceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
typeOptions := []string{"server", "metadata", "gateway"}
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Network interface resource schema. Must have a `region` specified in the provider configuration."),
Description: "Network interface resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`network_id`,`network_interface_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the network is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_id": schema.StringAttribute{
Description: "The network ID to which the network interface is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_interface_id": schema.StringAttribute{
Description: "The network interface ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the network interface.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"allowed_addresses": schema.ListAttribute{
Description: "The list of CIDR (Classless Inter-Domain Routing) notations.",
Optional: true,
Computed: true,
ElementType: types.StringType,
Validators: []validator.List{
listvalidator.ValueStringsAre(
validate.CIDR(),
),
},
},
"device": schema.StringAttribute{
Description: "The device UUID of the network interface.",
Computed: true,
},
"ipv4": schema.StringAttribute{
Description: "The IPv4 address.",
Optional: true,
Computed: true,
Validators: []validator.String{
validate.IP(),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a network interface.",
ElementType: types.StringType,
Optional: true,
},
"mac": schema.StringAttribute{
Description: "The MAC address of network interface.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"security": schema.BoolAttribute{
Description: "The Network Interface Security. If set to false, then no security groups will apply to this network interface.",
Computed: true,
Optional: true,
},
"security_group_ids": schema.ListAttribute{
Description: "The list of security group UUIDs. If security is set to false, setting this field will lead to an error.",
Optional: true,
Computed: true,
ElementType: types.StringType,
Validators: []validator.List{
listvalidator.ValueStringsAre(
stringvalidator.RegexMatches(
regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`),
"must match expression"),
),
},
},
"type": schema.StringAttribute{
Description: "Type of network interface. Some of the possible values are: " + utils.SupportedValuesDocumentation(typeOptions),
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *networkInterfaceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
networkId := model.NetworkId.ValueString()
ctx = tflog.SetField(ctx, "network_id", networkId)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new network interface
networkInterface, err := r.client.CreateNIC(ctx, projectId, networkId).CreateNICPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Calling API: %v", err))
return
}
networkInterfaceId := *networkInterface.Id
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
// Map response body to schema
err = mapFields(ctx, networkInterface, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network interface created")
}
// Read refreshes the Terraform state with the latest data.
func (r *networkInterfaceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
networkId := model.NetworkId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
networkInterfaceResp, err := r.client.GetNIC(ctx, projectId, networkId, networkInterfaceId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, networkInterfaceResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network interface read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *networkInterfaceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
networkId := model.NetworkId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing network
nicResp, err := r.client.UpdateNIC(ctx, projectId, networkId, networkInterfaceId).UpdateNICPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(ctx, nicResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network interface updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *networkInterfaceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
networkId := model.NetworkId.ValueString()
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
// Delete existing network interface
err := r.client.DeleteNIC(ctx, projectId, networkId, networkInterfaceId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network interface", fmt.Sprintf("Calling API: %v", err))
return
}
tflog.Info(ctx, "Network interface deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,network_id,network_interface_id
func (r *networkInterfaceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing network interface",
fmt.Sprintf("Expected import identifier with format: [project_id],[network_id],[network_interface_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
networkId := idParts[1]
networkInterfaceId := idParts[2]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "network_id", networkId)
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_id"), networkId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_interface_id"), networkInterfaceId)...)
tflog.Info(ctx, "Network interface state imported")
}
func mapFields(ctx context.Context, networkInterfaceResp *iaas.NIC, model *Model) error {
if networkInterfaceResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var networkInterfaceId string
if model.NetworkInterfaceId.ValueString() != "" {
networkInterfaceId = model.NetworkInterfaceId.ValueString()
} else if networkInterfaceResp.NetworkId != nil {
networkInterfaceId = *networkInterfaceResp.Id
} else {
return fmt.Errorf("network interface id not present")
}
idParts := []string{
model.ProjectId.ValueString(),
model.NetworkId.ValueString(),
networkInterfaceId,
}
model.Id = types.StringValue(
strings.Join(idParts, core.Separator),
)
respAllowedAddresses := []string{}
var diags diag.Diagnostics
if networkInterfaceResp.AllowedAddresses == nil {
model.AllowedAddresses = types.ListNull(types.StringType)
} else {
for _, n := range *networkInterfaceResp.AllowedAddresses {
respAllowedAddresses = append(respAllowedAddresses, *n.String)
}
modelAllowedAddresses, err := utils.ListValuetoStringSlice(model.AllowedAddresses)
if err != nil {
return fmt.Errorf("get current network interface allowed addresses from model: %w", err)
}
reconciledAllowedAddresses := utils.ReconcileStringSlices(modelAllowedAddresses, respAllowedAddresses)
allowedAddressesTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledAllowedAddresses)
if diags.HasError() {
return fmt.Errorf("map network interface allowed addresses: %w", core.DiagsToError(diags))
}
model.AllowedAddresses = allowedAddressesTF
}
if networkInterfaceResp.SecurityGroups == nil {
model.SecurityGroupIds = types.ListNull(types.StringType)
} else {
respSecurityGroups := *networkInterfaceResp.SecurityGroups
modelSecurityGroups, err := utils.ListValuetoStringSlice(model.SecurityGroupIds)
if err != nil {
return fmt.Errorf("get current network interface security groups from model: %w", err)
}
reconciledSecurityGroups := utils.ReconcileStringSlices(modelSecurityGroups, respSecurityGroups)
securityGroupsTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledSecurityGroups)
if diags.HasError() {
return fmt.Errorf("map network interface security groups: %w", core.DiagsToError(diags))
}
model.SecurityGroupIds = securityGroupsTF
}
labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{})
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
if networkInterfaceResp.Labels != nil && len(*networkInterfaceResp.Labels) != 0 {
var diags diag.Diagnostics
labels, diags = types.MapValueFrom(ctx, types.StringType, *networkInterfaceResp.Labels)
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
} else if model.Labels.IsNull() {
labels = types.MapNull(types.StringType)
}
model.NetworkInterfaceId = types.StringValue(networkInterfaceId)
model.Name = types.StringPointerValue(networkInterfaceResp.Name)
model.IPv4 = types.StringPointerValue(networkInterfaceResp.Ipv4)
model.Security = types.BoolPointerValue(networkInterfaceResp.NicSecurity)
model.Device = types.StringPointerValue(networkInterfaceResp.Device)
model.Mac = types.StringPointerValue(networkInterfaceResp.Mac)
model.Type = types.StringPointerValue(networkInterfaceResp.Type)
model.Labels = labels
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNICPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
var labelPayload *map[string]interface{}
modelSecurityGroups := []string{}
if !(model.SecurityGroupIds.IsNull() || model.SecurityGroupIds.IsUnknown()) {
for _, ns := range model.SecurityGroupIds.Elements() {
securityGroupString, ok := ns.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelSecurityGroups = append(modelSecurityGroups, securityGroupString.ValueString())
}
}
allowedAddressesPayload := []iaas.AllowedAddressesInner{}
if !(model.AllowedAddresses.IsNull() || model.AllowedAddresses.IsUnknown()) {
for _, allowedAddressModel := range model.AllowedAddresses.Elements() {
allowedAddressString, ok := allowedAddressModel.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
allowedAddressesPayload = append(allowedAddressesPayload, iaas.AllowedAddressesInner{
String: conversion.StringValueToPointer(allowedAddressString),
})
}
}
if !model.Labels.IsNull() && !model.Labels.IsUnknown() {
labelMap, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("mapping labels: %w", err)
}
labelPayload = &labelMap
}
return &iaas.CreateNICPayload{
AllowedAddresses: &allowedAddressesPayload,
SecurityGroups: &modelSecurityGroups,
Labels: labelPayload,
Name: conversion.StringValueToPointer(model.Name),
Device: conversion.StringValueToPointer(model.Device),
Ipv4: conversion.StringValueToPointer(model.IPv4),
Mac: conversion.StringValueToPointer(model.Mac),
Type: conversion.StringValueToPointer(model.Type),
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateNICPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
var labelPayload *map[string]interface{}
modelSecurityGroups := []string{}
for _, ns := range model.SecurityGroupIds.Elements() {
securityGroupString, ok := ns.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
modelSecurityGroups = append(modelSecurityGroups, securityGroupString.ValueString())
}
allowedAddressesPayload := []iaas.AllowedAddressesInner{}
if !(model.AllowedAddresses.IsNull() || model.AllowedAddresses.IsUnknown()) {
for _, allowedAddressModel := range model.AllowedAddresses.Elements() {
allowedAddressString, ok := allowedAddressModel.(types.String)
if !ok {
return nil, fmt.Errorf("type assertion failed")
}
allowedAddressesPayload = append(allowedAddressesPayload, iaas.AllowedAddressesInner{
String: conversion.StringValueToPointer(allowedAddressString),
})
}
}
if !model.Labels.IsNull() && !model.Labels.IsUnknown() {
labelMap, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("mapping labels: %w", err)
}
labelPayload = &labelMap
}
return &iaas.UpdateNICPayload{
AllowedAddresses: &allowedAddressesPayload,
SecurityGroups: &modelSecurityGroups,
Labels: labelPayload,
Name: conversion.StringValueToPointer(model.Name),
}, nil
}

View file

@ -0,0 +1,273 @@
package networkinterface
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
func TestMapFields(t *testing.T) {
tests := []struct {
description string
state Model
input *iaas.NIC
expected Model
isValid bool
}{
{
"id_ok",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
},
&iaas.NIC{
Id: utils.Ptr("nicid"),
},
Model{
Id: types.StringValue("pid,nid,nicid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
Name: types.StringNull(),
AllowedAddresses: types.ListNull(types.StringType),
SecurityGroupIds: types.ListNull(types.StringType),
IPv4: types.StringNull(),
Security: types.BoolNull(),
Device: types.StringNull(),
Mac: types.StringNull(),
Type: types.StringNull(),
Labels: types.MapNull(types.StringType),
},
true,
},
{
"values_ok",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
},
&iaas.NIC{
Id: utils.Ptr("nicid"),
Name: utils.Ptr("name"),
AllowedAddresses: &[]iaas.AllowedAddressesInner{
{
String: utils.Ptr("aa1"),
},
},
SecurityGroups: &[]string{
"prefix1",
"prefix2",
},
Ipv4: utils.Ptr("ipv4"),
Ipv6: utils.Ptr("ipv6"),
NicSecurity: utils.Ptr(true),
Device: utils.Ptr("device"),
Mac: utils.Ptr("mac"),
Status: utils.Ptr("status"),
Type: utils.Ptr("type"),
Labels: &map[string]interface{}{
"label1": "ref1",
},
},
Model{
Id: types.StringValue("pid,nid,nicid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
Name: types.StringValue("name"),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa1"),
}),
SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("prefix1"),
types.StringValue("prefix2"),
}),
IPv4: types.StringValue("ipv4"),
Security: types.BoolValue(true),
Device: types.StringValue("device"),
Mac: types.StringValue("mac"),
Type: types.StringValue("type"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"label1": types.StringValue("ref1")}),
},
true,
},
{
"allowed_addresses_changed_outside_tf",
Model{
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa1"),
}),
},
&iaas.NIC{
Id: utils.Ptr("nicid"),
AllowedAddresses: &[]iaas.AllowedAddressesInner{
{
String: utils.Ptr("aa2"),
},
},
},
Model{
Id: types.StringValue("pid,nid,nicid"),
ProjectId: types.StringValue("pid"),
NetworkId: types.StringValue("nid"),
NetworkInterfaceId: types.StringValue("nicid"),
Name: types.StringNull(),
SecurityGroupIds: types.ListNull(types.StringType),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa2"),
}),
Labels: types.MapNull(types.StringType),
},
true,
},
{
"response_nil_fail",
Model{},
nil,
Model{},
false,
},
{
"no_resource_id",
Model{
ProjectId: types.StringValue("pid"),
},
&iaas.NIC{},
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.input, &tt.state)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(tt.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.CreateNICPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("sg1"),
types.StringValue("sg2"),
}),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa1"),
}),
},
&iaas.CreateNICPayload{
Name: utils.Ptr("name"),
SecurityGroups: &[]string{
"sg1",
"sg2",
},
AllowedAddresses: &[]iaas.AllowedAddressesInner{
{
String: utils.Ptr("aa1"),
},
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.UpdateNICPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("sg1"),
types.StringValue("sg2"),
}),
AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{
types.StringValue("aa1"),
}),
},
&iaas.UpdateNICPayload{
Name: utils.Ptr("name"),
SecurityGroups: &[]string{
"sg1",
"sg2",
},
AllowedAddresses: &[]iaas.AllowedAddressesInner{
{
String: utils.Ptr("aa1"),
},
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -0,0 +1,303 @@
package networkinterfaceattach
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// resourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var resourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &networkInterfaceAttachResource{}
_ resource.ResourceWithConfigure = &networkInterfaceAttachResource{}
_ resource.ResourceWithImportState = &networkInterfaceAttachResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
ServerId types.String `tfsdk:"server_id"`
NetworkInterfaceId types.String `tfsdk:"network_interface_id"`
}
// NewNetworkInterfaceAttachResource is a helper function to simplify the provider implementation.
func NewNetworkInterfaceAttachResource() resource.Resource {
return &networkInterfaceAttachResource{}
}
// networkInterfaceAttachResource is the resource implementation.
type networkInterfaceAttachResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *networkInterfaceAttachResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_server_network_interface_attach"
}
// Configure adds the provider configured client to the resource.
func (r *networkInterfaceAttachResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !resourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_server_network_interface_attach", "resource")
if resp.Diagnostics.HasError() {
return
}
resourceBetaCheckDone = true
}
var apiClient *iaas.APIClient
var err error
if providerData.IaaSCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint)
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *networkInterfaceAttachResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Network interface attachment resource schema. Attaches a network interface to a server. Must have a `region` specified in the provider configuration."),
Description: "Network interface attachment resource schema. Attaches a network interface to a server. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`server_id`,`network_interface_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the network interface attachment is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"server_id": schema.StringAttribute{
Description: "The server ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"network_interface_id": schema.StringAttribute{
Description: "The network interface ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *networkInterfaceAttachResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "server_id", serverId)
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
// Create new network interface attachment
err := r.client.AddNICToServer(ctx, projectId, serverId, networkInterfaceId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching network interface to server", fmt.Sprintf("Calling API: %v", err))
return
}
idParts := []string{
projectId,
serverId,
networkInterfaceId,
}
model.Id = types.StringValue(
strings.Join(idParts, core.Separator),
)
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network interface attachment created")
}
// Read refreshes the Terraform state with the latest data.
func (r *networkInterfaceAttachResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "server_id", serverId)
networkInterfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId)
nics, err := r.client.ListServerNICs(ctx, projectId, serverId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface attachment", fmt.Sprintf("Calling API: %v", err))
return
}
if nics == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface attachment", "List of network interfaces attached to the server is nil")
return
}
if nics.Items != nil {
for _, nic := range *nics.Items {
if nic.Id == nil || (nic.Id != nil && *nic.Id != networkInterfaceId) {
continue
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Network interface attachment read")
return
}
}
// no matching network interface was found, the attachment no longer exists
resp.State.RemoveResource(ctx)
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *networkInterfaceAttachResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update is not supported, all fields require replace
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *networkInterfaceAttachResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "server_id", serverId)
network_interfaceId := model.NetworkInterfaceId.ValueString()
ctx = tflog.SetField(ctx, "network_interface_id", network_interfaceId)
// Remove network_interface from server
err := r.client.RemoveNICFromServer(ctx, projectId, serverId, network_interfaceId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing network interface from server", fmt.Sprintf("Calling API: %v", err))
return
}
tflog.Info(ctx, "Network interface attachment deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,server_id
func (r *networkInterfaceAttachResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing network_interface attachment",
fmt.Sprintf("Expected import identifier with format: [project_id],[server_id],[network_interface_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
serverId := idParts[1]
network_interfaceId := idParts[2]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "network_interface_id", network_interfaceId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_interface_id"), network_interfaceId)...)
tflog.Info(ctx, "Network interface attachment state imported")
}

View file

@ -0,0 +1,171 @@
package publicip
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// publicIpDataSourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var publicIpDataSourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &publicIpDataSource{}
)
// NewVolumeDataSource is a helper function to simplify the provider implementation.
func NewPublicIpDataSource() datasource.DataSource {
return &publicIpDataSource{}
}
// publicIpDataSource is the data source implementation.
type publicIpDataSource struct {
client *iaas.APIClient
}
// Metadata returns the data source type name.
func (d *publicIpDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_public_ip"
}
func (d *publicIpDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
var apiClient *iaas.APIClient
var err error
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !publicIpDataSourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_public_ip", "data source")
if resp.Diagnostics.HasError() {
return
}
publicIpDataSourceBetaCheckDone = true
}
if providerData.IaaSCustomEndpoint != "" {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err))
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *publicIpDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Volume resource schema. Must have a `region` specified in the provider configuration."),
Description: "Volume resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal datasource ID. It is structured as \"`project_id`,`public_ip_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the public IP is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"public_ip_id": schema.StringAttribute{
Description: "The public IP ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"ip": schema.StringAttribute{
Description: "The IP address.",
Computed: true,
},
"network_interface_id": schema.StringAttribute{
Description: "Associates the public IP with a network interface or a virtual IP (ID).",
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *publicIpDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
publicIpId := model.PublicIpId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
publicIpResp, err := d.client.GetPublicIP(ctx, projectId, publicIpId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(ctx, publicIpResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "public IP read")
}

View file

@ -0,0 +1,431 @@
package publicip
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// resourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var resourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &publicIpResource{}
_ resource.ResourceWithConfigure = &publicIpResource{}
_ resource.ResourceWithImportState = &publicIpResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
PublicIpId types.String `tfsdk:"public_ip_id"`
Ip types.String `tfsdk:"ip"`
NetworkInterfaceId types.String `tfsdk:"network_interface_id"`
Labels types.Map `tfsdk:"labels"`
}
// NewPublicIpResource is a helper function to simplify the provider implementation.
func NewPublicIpResource() resource.Resource {
return &publicIpResource{}
}
// publicIpResource is the resource implementation.
type publicIpResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *publicIpResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_public_ip"
}
// Configure adds the provider configured client to the resource.
func (r *publicIpResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !resourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_public_ip", "resource")
if resp.Diagnostics.HasError() {
return
}
resourceBetaCheckDone = true
}
var apiClient *iaas.APIClient
var err error
if providerData.IaaSCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint)
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *publicIpResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Public IP resource schema. Must have a `region` specified in the provider configuration."),
Description: "Public IP resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`public_ip_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the public IP is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"public_ip_id": schema.StringAttribute{
Description: "The public IP ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"ip": schema.StringAttribute{
Description: "The IP address.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.IP(),
},
},
"network_interface_id": schema.StringAttribute{
Description: "Associates the public IP with a network interface or a virtual IP (ID).",
Optional: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *publicIpResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating public IP", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new public IP
publicIp, err := r.client.CreatePublicIP(ctx, projectId).CreatePublicIPPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating public IP", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = tflog.SetField(ctx, "public_ip_id", *publicIp.Id)
// Map response body to schema
err = mapFields(ctx, publicIp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating public IP", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Public IP created")
}
// Read refreshes the Terraform state with the latest data.
func (r *publicIpResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
publicIpId := model.PublicIpId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
publicIpResp, err := r.client.GetPublicIP(ctx, projectId, publicIpId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, publicIpResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading public IP", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "public IP read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *publicIpResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
publicIpId := model.PublicIpId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating public IP", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing public IP
updatedPublicIp, err := r.client.UpdatePublicIP(ctx, projectId, publicIpId).UpdatePublicIPPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating public IP", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(ctx, updatedPublicIp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating public IP", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "public IP updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *publicIpResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
publicIpId := model.PublicIpId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
// Delete existing publicIp
err := r.client.DeletePublicIP(ctx, projectId, publicIpId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting public IP", fmt.Sprintf("Calling API: %v", err))
return
}
tflog.Info(ctx, "public IP deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,public_ip_id
func (r *publicIpResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing public IP",
fmt.Sprintf("Expected import identifier with format: [project_id],[public_ip_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
publicIpId := idParts[1]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "public_ip_id", publicIpId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("public_ip_id"), publicIpId)...)
tflog.Info(ctx, "public IP state imported")
}
func mapFields(ctx context.Context, publicIpResp *iaas.PublicIp, model *Model) error {
if publicIpResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var publicIpId string
if model.PublicIpId.ValueString() != "" {
publicIpId = model.PublicIpId.ValueString()
} else if publicIpResp.Id != nil {
publicIpId = *publicIpResp.Id
} else {
return fmt.Errorf("public IP id not present")
}
idParts := []string{
model.ProjectId.ValueString(),
publicIpId,
}
model.Id = types.StringValue(
strings.Join(idParts, core.Separator),
)
labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{})
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
if publicIpResp.Labels != nil && len(*publicIpResp.Labels) != 0 {
var diags diag.Diagnostics
labels, diags = types.MapValueFrom(ctx, types.StringType, *publicIpResp.Labels)
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
} else if model.Labels.IsNull() {
labels = types.MapNull(types.StringType)
}
model.PublicIpId = types.StringValue(publicIpId)
model.Ip = types.StringPointerValue(publicIpResp.Ip)
if publicIpResp.NetworkInterface != nil {
model.NetworkInterfaceId = types.StringPointerValue(publicIpResp.GetNetworkInterface())
} else {
model.NetworkInterfaceId = types.StringNull()
}
model.Labels = labels
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreatePublicIPPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.CreatePublicIPPayload{
Labels: &labels,
Ip: conversion.StringValueToPointer(model.Ip),
NetworkInterface: iaas.NewNullableString(conversion.StringValueToPointer(model.NetworkInterfaceId)),
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdatePublicIPPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.UpdatePublicIPPayload{
Labels: &labels,
NetworkInterface: iaas.NewNullableString(conversion.StringValueToPointer(model.NetworkInterfaceId)),
}, nil
}

View file

@ -0,0 +1,264 @@
package publicip
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
func TestMapFields(t *testing.T) {
tests := []struct {
description string
state Model
input *iaas.PublicIp
expected Model
isValid bool
}{
{
"default_values",
Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
},
&iaas.PublicIp{
Id: utils.Ptr("pipid"),
NetworkInterface: iaas.NewNullableString(nil),
},
Model{
Id: types.StringValue("pid,pipid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringNull(),
Labels: types.MapNull(types.StringType),
NetworkInterfaceId: types.StringNull(),
},
true,
},
{
"simple_values",
Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
},
&iaas.PublicIp{
Id: utils.Ptr("pipid"),
Ip: utils.Ptr("ip"),
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")),
},
Model{
Id: types.StringValue("pid,pipid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
NetworkInterfaceId: types.StringValue("interface"),
},
true,
},
{
"empty_labels",
Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
&iaas.PublicIp{
Id: utils.Ptr("pipid"),
NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")),
},
Model{
Id: types.StringValue("pid,pipid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringNull(),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
NetworkInterfaceId: types.StringValue("interface"),
},
true,
},
{
"network_interface_id_nil",
Model{
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
},
&iaas.PublicIp{
Id: utils.Ptr("pipid"),
},
Model{
Id: types.StringValue("pid,pipid"),
ProjectId: types.StringValue("pid"),
PublicIpId: types.StringValue("pipid"),
Ip: types.StringNull(),
Labels: types.MapNull(types.StringType),
NetworkInterfaceId: types.StringNull(),
},
true,
},
{
"response_nil_fail",
Model{},
nil,
Model{},
false,
},
{
"no_resource_id",
Model{
ProjectId: types.StringValue("pid"),
},
&iaas.PublicIp{},
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.input, &tt.state)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(tt.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.CreatePublicIPPayload
isValid bool
}{
{
"default_ok",
&Model{
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
NetworkInterfaceId: types.StringValue("interface"),
},
&iaas.CreatePublicIPPayload{
Ip: utils.Ptr("ip"),
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")),
},
true,
},
{
"network_interface_nil",
&Model{
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.CreatePublicIPPayload{
Ip: utils.Ptr("ip"),
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(nil),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.UpdatePublicIPPayload
isValid bool
}{
{
"default_ok",
&Model{
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
NetworkInterfaceId: types.StringValue("interface"),
},
&iaas.UpdatePublicIPPayload{
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(utils.Ptr("interface")),
},
true,
},
{
"network_interface_nil",
&Model{
Ip: types.StringValue("ip"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.UpdatePublicIPPayload{
Labels: &map[string]interface{}{
"key": "value",
},
NetworkInterface: iaas.NewNullableString(nil),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected, cmp.AllowUnexported(iaas.NullableString{}))
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -0,0 +1,171 @@
package securitygroup
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// securityGroupDataSourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var securityGroupDataSourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &securityGroupDataSource{}
)
// NewSecurityGroupDataSource is a helper function to simplify the provider implementation.
func NewSecurityGroupDataSource() datasource.DataSource {
return &securityGroupDataSource{}
}
// securityGroupDataSource is the data source implementation.
type securityGroupDataSource struct {
client *iaas.APIClient
}
// Metadata returns the data source type name.
func (d *securityGroupDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_security_group"
}
func (d *securityGroupDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
var apiClient *iaas.APIClient
var err error
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !securityGroupDataSourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_security_group", "data source")
if resp.Diagnostics.HasError() {
return
}
securityGroupDataSourceBetaCheckDone = true
}
if providerData.IaaSCustomEndpoint != "" {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err))
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *securityGroupDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Security group datasource schema. Must have a `region` specified in the provider configuration."),
Description: "Security group datasource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`security_group_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the security group is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"security_group_id": schema.StringAttribute{
Description: "The security group ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the security group.",
Computed: true,
},
"description": schema.StringAttribute{
Description: "The description of the security group.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
"stateful": schema.BoolAttribute{
Description: "Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server.",
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *securityGroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
securityGroupId := model.SecurityGroupId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
securityGroupResp, err := d.client.GetSecurityGroup(ctx, projectId, securityGroupId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(ctx, securityGroupResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "security group read")
}

View file

@ -0,0 +1,451 @@
package securitygroup
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// resourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var resourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &securityGroupResource{}
_ resource.ResourceWithConfigure = &securityGroupResource{}
_ resource.ResourceWithImportState = &securityGroupResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
SecurityGroupId types.String `tfsdk:"security_group_id"`
Name types.String `tfsdk:"name"`
Description types.String `tfsdk:"description"`
Labels types.Map `tfsdk:"labels"`
Stateful types.Bool `tfsdk:"stateful"`
}
// NewSecurityGroupResource is a helper function to simplify the provider implementation.
func NewSecurityGroupResource() resource.Resource {
return &securityGroupResource{}
}
// securityGroupResource is the resource implementation.
type securityGroupResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *securityGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_security_group"
}
// Configure adds the provider configured client to the resource.
func (r *securityGroupResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !resourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_security_group", "resource")
if resp.Diagnostics.HasError() {
return
}
resourceBetaCheckDone = true
}
var apiClient *iaas.APIClient
var err error
if providerData.IaaSCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint)
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *securityGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Security group resource schema. Must have a `region` specified in the provider configuration."),
Description: "Security group resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`security_group_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the security group is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"security_group_id": schema.StringAttribute{
Description: "The security group ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the security group.",
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"description": schema.StringAttribute{
Description: "The description of the security group.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(127),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
"stateful": schema.BoolAttribute{
Description: "Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.RequiresReplace(),
boolplanmodifier.UseStateForUnknown(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *securityGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new security group
securityGroup, err := r.client.CreateSecurityGroup(ctx, projectId).CreateSecurityGroupPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Calling API: %v", err))
return
}
securityGroupId := *securityGroup.Id
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
// Map response body to schema
err = mapFields(ctx, securityGroup, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Security group created")
}
// Read refreshes the Terraform state with the latest data.
func (r *securityGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
securityGroupId := model.SecurityGroupId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "security_id", securityGroupId)
securityGroupResp, err := r.client.GetSecurityGroup(ctx, projectId, securityGroupId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, securityGroupResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "security group read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *securityGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
securityGroupId := model.SecurityGroupId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing security group
updatedSecurityGroup, err := r.client.UpdateSecurityGroup(ctx, projectId, securityGroupId).UpdateSecurityGroupPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(ctx, updatedSecurityGroup, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "security group updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *securityGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
securityGroupId := model.SecurityGroupId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
// Delete existing security group
err := r.client.DeleteSecurityGroup(ctx, projectId, securityGroupId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting security group", fmt.Sprintf("Calling API: %v", err))
return
}
tflog.Info(ctx, "security group deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,security_group_id
func (r *securityGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing security group",
fmt.Sprintf("Expected import identifier with format: [project_id],[security_group_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
securityGroupId := idParts[1]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("security_group_id"), securityGroupId)...)
tflog.Info(ctx, "security group state imported")
}
func mapFields(ctx context.Context, securityGroupResp *iaas.SecurityGroup, model *Model) error {
if securityGroupResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var securityGroupId string
if model.SecurityGroupId.ValueString() != "" {
securityGroupId = model.SecurityGroupId.ValueString()
} else if securityGroupResp.Id != nil {
securityGroupId = *securityGroupResp.Id
} else {
return fmt.Errorf("security group id not present")
}
idParts := []string{
model.ProjectId.ValueString(),
securityGroupId,
}
model.Id = types.StringValue(
strings.Join(idParts, core.Separator),
)
labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{})
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
if securityGroupResp.Labels != nil && len(*securityGroupResp.Labels) != 0 {
var diags diag.Diagnostics
labels, diags = types.MapValueFrom(ctx, types.StringType, *securityGroupResp.Labels)
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
} else if model.Labels.IsNull() {
labels = types.MapNull(types.StringType)
}
model.SecurityGroupId = types.StringValue(securityGroupId)
model.Name = types.StringPointerValue(securityGroupResp.Name)
model.Description = types.StringPointerValue(securityGroupResp.Description)
model.Stateful = types.BoolPointerValue(securityGroupResp.Stateful)
model.Labels = labels
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateSecurityGroupPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.CreateSecurityGroupPayload{
Stateful: conversion.BoolValueToPointer(model.Stateful),
Description: conversion.StringValueToPointer(model.Description),
Labels: &labels,
Name: conversion.StringValueToPointer(model.Name),
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateSecurityGroupPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.UpdateSecurityGroupPayload{
Description: conversion.StringValueToPointer(model.Description),
Name: conversion.StringValueToPointer(model.Name),
Labels: &labels,
}, nil
}

View file

@ -0,0 +1,218 @@
package securitygroup
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
func TestMapFields(t *testing.T) {
tests := []struct {
description string
state Model
input *iaas.SecurityGroup
expected Model
isValid bool
}{
{
"default_values",
Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
},
&iaas.SecurityGroup{
Id: utils.Ptr("sgid"),
},
Model{
Id: types.StringValue("pid,sgid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
Name: types.StringNull(),
Labels: types.MapNull(types.StringType),
Description: types.StringNull(),
Stateful: types.BoolNull(),
},
true,
},
{
"simple_values",
Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
},
// &sourceModel{},
&iaas.SecurityGroup{
Id: utils.Ptr("sgid"),
Name: utils.Ptr("name"),
Stateful: utils.Ptr(true),
Labels: &map[string]interface{}{
"key": "value",
},
Description: utils.Ptr("desc"),
},
Model{
Id: types.StringValue("pid,sgid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
Name: types.StringValue("name"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Description: types.StringValue("desc"),
Stateful: types.BoolValue(true),
},
true,
},
{
"empty_labels",
Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
},
&iaas.SecurityGroup{
Id: utils.Ptr("sgid"),
Labels: &map[string]interface{}{},
},
Model{
Id: types.StringValue("pid,sgid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
Name: types.StringNull(),
Labels: types.MapNull(types.StringType),
Description: types.StringNull(),
Stateful: types.BoolNull(),
},
true,
},
{
"response_nil_fail",
Model{},
nil,
Model{},
false,
},
{
"no_resource_id",
Model{
ProjectId: types.StringValue("pid"),
},
&iaas.SecurityGroup{},
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.input, &tt.state)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(tt.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.CreateSecurityGroupPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
Stateful: types.BoolValue(true),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Description: types.StringValue("desc"),
},
&iaas.CreateSecurityGroupPayload{
Name: utils.Ptr("name"),
Stateful: utils.Ptr(true),
Labels: &map[string]interface{}{
"key": "value",
},
Description: utils.Ptr("desc"),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.UpdateSecurityGroupPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Description: types.StringValue("desc"),
},
&iaas.UpdateSecurityGroupPayload{
Name: utils.Ptr("name"),
Labels: &map[string]interface{}{
"key": "value",
},
Description: utils.Ptr("desc"),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -0,0 +1,228 @@
package securitygrouprule
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// securityGroupRuleDataSourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var securityGroupRuleDataSourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &securityGroupRuleDataSource{}
)
// NewSecurityGroupRuleDataSource is a helper function to simplify the provider implementation.
func NewSecurityGroupRuleDataSource() datasource.DataSource {
return &securityGroupRuleDataSource{}
}
// securityGroupRuleDataSource is the data source implementation.
type securityGroupRuleDataSource struct {
client *iaas.APIClient
}
// Metadata returns the data source type name.
func (d *securityGroupRuleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_security_group_rule"
}
func (d *securityGroupRuleDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
var apiClient *iaas.APIClient
var err error
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !securityGroupRuleDataSourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_security_group_rule", "data source")
if resp.Diagnostics.HasError() {
return
}
securityGroupRuleDataSourceBetaCheckDone = true
}
if providerData.IaaSCustomEndpoint != "" {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err))
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *securityGroupRuleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
directionOptions := []string{"ingress", "egress"}
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Security group datasource schema. Must have a `region` specified in the provider configuration."),
Description: "Security group datasource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal datasource ID. It is structured as \"`project_id`,`security_group_id`,`security_group_rule_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the security group rule is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"security_group_id": schema.StringAttribute{
Description: "The security group ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"security_group_rule_id": schema.StringAttribute{
Description: "The security group rule ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"direction": schema.StringAttribute{
Description: "The direction of the traffic which the rule should match. Some of the possible values are: " + utils.SupportedValuesDocumentation(directionOptions),
Computed: true,
},
"description": schema.StringAttribute{
Description: "The description of the security group rule.",
Computed: true,
},
"ether_type": schema.StringAttribute{
Description: "The ethertype which the rule should match.",
Computed: true,
},
"icmp_parameters": schema.SingleNestedAttribute{
Description: "ICMP Parameters.",
Computed: true,
Attributes: map[string]schema.Attribute{
"code": schema.Int64Attribute{
Description: "ICMP code. Can be set if the protocol is ICMP.",
Computed: true,
},
"type": schema.Int64Attribute{
Description: "ICMP type. Can be set if the protocol is ICMP.",
Computed: true,
},
},
},
"ip_range": schema.StringAttribute{
Description: "The remote IP range which the rule should match.",
Computed: true,
},
"port_range": schema.SingleNestedAttribute{
Description: "The range of ports.",
Computed: true,
Attributes: map[string]schema.Attribute{
"max": schema.Int64Attribute{
Description: "The maximum port number. Should be greater or equal to the minimum.",
Computed: true,
},
"min": schema.Int64Attribute{
Description: "The minimum port number. Should be less or equal to the minimum.",
Computed: true,
},
},
},
"protocol": schema.SingleNestedAttribute{
Description: "The internet protocol which the rule should match.",
Computed: true,
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "The protocol name which the rule should match.",
Computed: true,
},
"number": schema.Int64Attribute{
Description: "The protocol number which the rule should match.",
Computed: true,
},
},
},
"remote_security_group_id": schema.StringAttribute{
Description: "The remote security group which the rule should match.",
Computed: true,
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *securityGroupRuleDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
securityGroupId := model.SecurityGroupId.ValueString()
securityGroupRuleId := model.SecurityGroupRuleId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId)
securityGroupRuleResp, err := d.client.GetSecurityGroupRule(ctx, projectId, securityGroupId, securityGroupRuleId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group rule", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(securityGroupRuleResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group rule", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "security group rule read")
}

View file

@ -0,0 +1,93 @@
package securitygrouprule
import (
"context"
"slices"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
)
// UseNullForUnknownBasedOnProtocolModifier returns a plan modifier that sets a null
// value into the planned value, based on the value of the protocol.name attribute.
//
// To prevent Terraform errors, the framework automatically sets unconfigured
// and Computed attributes to an unknown value "(known after apply)" on update.
// To prevent always showing "(known after apply)" on update for an attribute, e.g. port_range, which never changes in case the protocol is a specific one,
// we set the value to null.
// Examples: port_range is only computed if protocol is not icmp and icmp_parameters is only computed if protocol is icmp
func UseNullForUnknownBasedOnProtocolModifier() planmodifier.Object {
return useNullForUnknownBasedOnProtocolModifier{}
}
// useNullForUnknownBasedOnProtocolModifier implements the plan modifier.
type useNullForUnknownBasedOnProtocolModifier struct{}
func (m useNullForUnknownBasedOnProtocolModifier) Description(_ context.Context) string {
return "If protocol.name attribute is set and the value corresponds to an icmp protocol, the value of this attribute in state will be set to null."
}
// MarkdownDescription returns a markdown description of the plan modifier.
func (m useNullForUnknownBasedOnProtocolModifier) MarkdownDescription(_ context.Context) string {
return "Once set, the value of this attribute in state will be set to null if protocol.name attribute is set and the value corresponds to an icmp protocol."
}
// PlanModifyBool implements the plan modification logic.
func (m useNullForUnknownBasedOnProtocolModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { // nolint:gocritic // function signature required by Terraform
// Check if the resource is being created.
if req.State.Raw.IsNull() {
return
}
// Do nothing if there is a known planned value.
if !req.PlanValue.IsUnknown() {
return
}
// Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up.
if req.ConfigValue.IsUnknown() {
return
}
// If there is an unknown configuration value, check if the value of protocol.name attribute corresponds to an icmp protocol. If it does, set the attribute value to null
var model Model
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
// If protocol is not configured, return without error.
if model.Protocol.IsNull() || model.Protocol.IsUnknown() {
return
}
protocol := &protocolModel{}
diags := model.Protocol.As(ctx, protocol, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
protocolName := conversion.StringValueToPointer(protocol.Name)
if protocolName == nil {
return
}
if slices.Contains(icmpProtocols, *protocolName) {
if model.PortRange.IsUnknown() {
resp.PlanValue = types.ObjectNull(portRangeTypes)
return
}
} else {
if model.IcmpParameters.IsUnknown() {
resp.PlanValue = types.ObjectNull(icmpParametersTypes)
return
}
}
// use state for unknown if the value was not set to null
resp.PlanValue = req.StateValue
}

View file

@ -0,0 +1,777 @@
package securitygrouprule
import (
"context"
"fmt"
"net/http"
"slices"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// resourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var resourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &securityGroupRuleResource{}
_ resource.ResourceWithConfigure = &securityGroupRuleResource{}
_ resource.ResourceWithImportState = &securityGroupRuleResource{}
icmpProtocols = []string{"icmp", "ipv6-icmp"}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
SecurityGroupId types.String `tfsdk:"security_group_id"`
SecurityGroupRuleId types.String `tfsdk:"security_group_rule_id"`
Direction types.String `tfsdk:"direction"`
Description types.String `tfsdk:"description"`
EtherType types.String `tfsdk:"ether_type"`
IcmpParameters types.Object `tfsdk:"icmp_parameters"`
IpRange types.String `tfsdk:"ip_range"`
PortRange types.Object `tfsdk:"port_range"`
Protocol types.Object `tfsdk:"protocol"`
RemoteSecurityGroupId types.String `tfsdk:"remote_security_group_id"`
}
type icmpParametersModel struct {
Code types.Int64 `tfsdk:"code"`
Type types.Int64 `tfsdk:"type"`
}
// Types corresponding to icmpParameters
var icmpParametersTypes = map[string]attr.Type{
"code": basetypes.Int64Type{},
"type": basetypes.Int64Type{},
}
type portRangeModel struct {
Max types.Int64 `tfsdk:"max"`
Min types.Int64 `tfsdk:"min"`
}
// Types corresponding to portRange
var portRangeTypes = map[string]attr.Type{
"max": basetypes.Int64Type{},
"min": basetypes.Int64Type{},
}
type protocolModel struct {
Name types.String `tfsdk:"name"`
Number types.Int64 `tfsdk:"number"`
}
// Types corresponding to protocol
var protocolTypes = map[string]attr.Type{
"name": basetypes.StringType{},
"number": basetypes.Int64Type{},
}
// NewSecurityGroupRuleResource is a helper function to simplify the provider implementation.
func NewSecurityGroupRuleResource() resource.Resource {
return &securityGroupRuleResource{}
}
// securityGroupRuleResource is the resource implementation.
type securityGroupRuleResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *securityGroupRuleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_security_group_rule"
}
// Configure adds the provider configured client to the resource.
func (r *securityGroupRuleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !resourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_security_group_rule", "resource")
if resp.Diagnostics.HasError() {
return
}
resourceBetaCheckDone = true
}
var apiClient *iaas.APIClient
var err error
if providerData.IaaSCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint)
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
func (r securityGroupRuleResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var model Model
resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
if resp.Diagnostics.HasError() {
return
}
// If protocol is not configured, return without error.
if model.Protocol.IsNull() || model.Protocol.IsUnknown() {
return
}
protocol := &protocolModel{}
diags := model.Protocol.As(ctx, protocol, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
protocolName := conversion.StringValueToPointer(protocol.Name)
if protocolName == nil {
return
}
if slices.Contains(icmpProtocols, *protocolName) {
if !(model.PortRange.IsNull() || model.PortRange.IsUnknown()) {
resp.Diagnostics.AddAttributeError(
path.Root("port_range"),
"Conflicting attribute configuration",
"`port_range` attribute can't be provided if `protocol.name` is set to `icmp` or `ipv6-icmp`",
)
}
} else {
if !(model.IcmpParameters.IsNull() || model.IcmpParameters.IsUnknown()) {
resp.Diagnostics.AddAttributeError(
path.Root("icmp_parameters"),
"Conflicting attribute configuration",
"`icmp_parameters` attribute can't be provided if `protocol.name` is not `icmp` or `ipv6-icmp`",
)
}
}
}
// Schema defines the schema for the resource.
func (r *securityGroupRuleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
directionOptions := []string{"ingress", "egress"}
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Security group rule resource schema. Must have a `region` specified in the provider configuration."),
Description: "Security group rule resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`security_group_id`,`security_group_rule_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the security group rule is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"security_group_id": schema.StringAttribute{
Description: "The security group ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"security_group_rule_id": schema.StringAttribute{
Description: "The security group rule ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"description": schema.StringAttribute{
Description: "The rule description.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplaceIfConfigured(),
},
Validators: []validator.String{
stringvalidator.LengthAtMost(127),
},
},
"direction": schema.StringAttribute{
Description: "The direction of the traffic which the rule should match. Some of the possible values are: " + utils.SupportedValuesDocumentation(directionOptions),
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ether_type": schema.StringAttribute{
Description: "The ethertype which the rule should match.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplaceIfConfigured(),
},
},
"icmp_parameters": schema.SingleNestedAttribute{
Description: "ICMP Parameters. These parameters should only be provided if the protocol is ICMP.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Object{
UseNullForUnknownBasedOnProtocolModifier(),
objectplanmodifier.RequiresReplaceIfConfigured(),
},
Attributes: map[string]schema.Attribute{
"code": schema.Int64Attribute{
Description: "ICMP code. Can be set if the protocol is ICMP.",
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(255),
},
},
"type": schema.Int64Attribute{
Description: "ICMP type. Can be set if the protocol is ICMP.",
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(255),
},
},
},
},
"ip_range": schema.StringAttribute{
Description: "The remote IP range which the rule should match.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.IP(),
},
},
"port_range": schema.SingleNestedAttribute{
Description: "The range of ports. This should only be provided if the protocol is not ICMP.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.RequiresReplaceIfConfigured(),
UseNullForUnknownBasedOnProtocolModifier(),
},
Attributes: map[string]schema.Attribute{
"max": schema.Int64Attribute{
Description: "The maximum port number. Should be greater or equal to the minimum.",
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(65535),
},
},
"min": schema.Int64Attribute{
Description: "The minimum port number. Should be less or equal to the maximum.",
Required: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(65535),
},
},
},
},
"protocol": schema.SingleNestedAttribute{
Description: "The internet protocol which the rule should match.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.RequiresReplaceIfConfigured(),
objectplanmodifier.UseStateForUnknown(),
},
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
Description: "The protocol name which the rule should match. Either `name` or `number` must be provided.",
Optional: true,
Computed: true,
Validators: []validator.String{
stringvalidator.AtLeastOneOf(
path.MatchRoot("protocol").AtName("number"),
),
stringvalidator.ConflictsWith(
path.MatchRoot("protocol").AtName("number"),
),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
stringplanmodifier.RequiresReplaceIfConfigured(),
},
},
"number": schema.Int64Attribute{
Description: "The protocol number which the rule should match. Either `name` or `number` must be provided.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
int64planmodifier.RequiresReplaceIfConfigured(),
},
Validators: []validator.Int64{
int64validator.AtLeast(0),
int64validator.AtMost(255),
},
},
},
},
"remote_security_group_id": schema.StringAttribute{
Description: "The remote security group which the rule should match.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *securityGroupRuleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
securityGroupId := model.SecurityGroupId.ValueString()
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
var icmpParameters *icmpParametersModel
if !(model.IcmpParameters.IsNull() || model.IcmpParameters.IsUnknown()) {
icmpParameters = &icmpParametersModel{}
diags = model.IcmpParameters.As(ctx, icmpParameters, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var portRange *portRangeModel
if !(model.PortRange.IsNull() || model.PortRange.IsUnknown()) {
portRange = &portRangeModel{}
diags = model.PortRange.As(ctx, portRange, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
var protocol *protocolModel
if !(model.Protocol.IsNull() || model.Protocol.IsUnknown()) {
protocol = &protocolModel{}
diags = model.Protocol.As(ctx, protocol, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Generate API request body from model
payload, err := toCreatePayload(&model, icmpParameters, portRange, protocol)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group rule", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new security group rule
securityGroupRule, err := r.client.CreateSecurityGroupRule(ctx, projectId, securityGroupId).CreateSecurityGroupRulePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group rule", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = tflog.SetField(ctx, "security_group_rule_id", *securityGroupRule.Id)
// Map response body to schema
err = mapFields(securityGroupRule, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group rule", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Security group rule created")
}
// Read refreshes the Terraform state with the latest data.
func (r *securityGroupRuleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
securityGroupId := model.SecurityGroupId.ValueString()
securityGroupRuleId := model.SecurityGroupRuleId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId)
securityGroupRuleResp, err := r.client.GetSecurityGroupRule(ctx, projectId, securityGroupId, securityGroupRuleId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group rule", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(securityGroupRuleResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group rule", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "security group rule read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *securityGroupRuleResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update shouldn't be called
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group rule", "Security group rule can't be updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *securityGroupRuleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
securityGroupId := model.SecurityGroupId.ValueString()
securityGroupRuleId := model.SecurityGroupRuleId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId)
// Delete existing security group rule
err := r.client.DeleteSecurityGroupRule(ctx, projectId, securityGroupId, securityGroupRuleId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting security group rule", fmt.Sprintf("Calling API: %v", err))
return
}
tflog.Info(ctx, "security group rule deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,security_group_id, security_group_rule_id
func (r *securityGroupRuleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing security group rule",
fmt.Sprintf("Expected import identifier with format: [project_id],[security_group_id],[security_group_rule_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
securityGroupId := idParts[1]
securityGroupRuleId := idParts[2]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "security_group_id", securityGroupId)
ctx = tflog.SetField(ctx, "security_group_rule_id", securityGroupRuleId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("security_group_id"), securityGroupId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("security_group_rule_id"), securityGroupRuleId)...)
tflog.Info(ctx, "security group rule state imported")
}
func mapFields(securityGroupRuleResp *iaas.SecurityGroupRule, model *Model) error {
if securityGroupRuleResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var securityGroupRuleId string
if model.SecurityGroupRuleId.ValueString() != "" {
securityGroupRuleId = model.SecurityGroupRuleId.ValueString()
} else if securityGroupRuleResp.Id != nil {
securityGroupRuleId = *securityGroupRuleResp.Id
} else {
return fmt.Errorf("security group rule id not present")
}
idParts := []string{
model.ProjectId.ValueString(),
model.SecurityGroupId.ValueString(),
securityGroupRuleId,
}
model.Id = types.StringValue(
strings.Join(idParts, core.Separator),
)
model.SecurityGroupRuleId = types.StringValue(securityGroupRuleId)
model.Direction = types.StringPointerValue(securityGroupRuleResp.Direction)
model.Description = types.StringPointerValue(securityGroupRuleResp.Description)
model.EtherType = types.StringPointerValue(securityGroupRuleResp.Ethertype)
model.IpRange = types.StringPointerValue(securityGroupRuleResp.IpRange)
model.RemoteSecurityGroupId = types.StringPointerValue(securityGroupRuleResp.RemoteSecurityGroupId)
err := mapIcmpParameters(securityGroupRuleResp, model)
if err != nil {
return fmt.Errorf("map icmp_parameters: %w", err)
}
err = mapPortRange(securityGroupRuleResp, model)
if err != nil {
return fmt.Errorf("map port_range: %w", err)
}
err = mapProtocol(securityGroupRuleResp, model)
if err != nil {
return fmt.Errorf("map protocol: %w", err)
}
return nil
}
func mapIcmpParameters(securityGroupRuleResp *iaas.SecurityGroupRule, m *Model) error {
if securityGroupRuleResp.IcmpParameters == nil {
m.IcmpParameters = types.ObjectNull(icmpParametersTypes)
return nil
}
icmpParametersValues := map[string]attr.Value{
"type": types.Int64Value(*securityGroupRuleResp.IcmpParameters.Type),
"code": types.Int64Value(*securityGroupRuleResp.IcmpParameters.Code),
}
icmpParametersObject, diags := types.ObjectValue(icmpParametersTypes, icmpParametersValues)
if diags.HasError() {
return fmt.Errorf("create icmpParameters object: %w", core.DiagsToError(diags))
}
m.IcmpParameters = icmpParametersObject
return nil
}
func mapPortRange(securityGroupRuleResp *iaas.SecurityGroupRule, m *Model) error {
if securityGroupRuleResp.PortRange == nil {
m.PortRange = types.ObjectNull(portRangeTypes)
return nil
}
portRangeMax := types.Int64Null()
portRangeMin := types.Int64Null()
if securityGroupRuleResp.PortRange.Max != nil {
portRangeMax = types.Int64Value(*securityGroupRuleResp.PortRange.Max)
}
if securityGroupRuleResp.PortRange.Min != nil {
portRangeMin = types.Int64Value(*securityGroupRuleResp.PortRange.Min)
}
portRangeValues := map[string]attr.Value{
"max": portRangeMax,
"min": portRangeMin,
}
portRangeObject, diags := types.ObjectValue(portRangeTypes, portRangeValues)
if diags.HasError() {
return fmt.Errorf("create portRange object: %w", core.DiagsToError(diags))
}
m.PortRange = portRangeObject
return nil
}
func mapProtocol(securityGroupRuleResp *iaas.SecurityGroupRule, m *Model) error {
if securityGroupRuleResp.Protocol == nil {
m.Protocol = types.ObjectNull(protocolTypes)
return nil
}
protocolNumberValue := types.Int64Null()
if securityGroupRuleResp.Protocol.Number != nil {
protocolNumberValue = types.Int64Value(*securityGroupRuleResp.Protocol.Number)
}
protocolNameValue := types.StringNull()
if securityGroupRuleResp.Protocol.Name != nil {
protocolNameValue = types.StringValue(*securityGroupRuleResp.Protocol.Name)
}
protocolValues := map[string]attr.Value{
"name": protocolNameValue,
"number": protocolNumberValue,
}
protocolObject, diags := types.ObjectValue(protocolTypes, protocolValues)
if diags.HasError() {
return fmt.Errorf("create protocol object: %w", core.DiagsToError(diags))
}
m.Protocol = protocolObject
return nil
}
func toCreatePayload(model *Model, icmpParameters *icmpParametersModel, portRange *portRangeModel, protocol *protocolModel) (*iaas.CreateSecurityGroupRulePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
payloadIcmpParameters, err := toIcmpParametersPayload(icmpParameters)
if err != nil {
return nil, fmt.Errorf("converting icmp parameters: %w", err)
}
payloadPortRange, err := toPortRangePayload(portRange)
if err != nil {
return nil, fmt.Errorf("converting port range: %w", err)
}
payloadProtocol, err := toProtocolPayload(protocol)
if err != nil {
return nil, fmt.Errorf("converting protocol: %w", err)
}
return &iaas.CreateSecurityGroupRulePayload{
Description: conversion.StringValueToPointer(model.Description),
Direction: conversion.StringValueToPointer(model.Direction),
Ethertype: conversion.StringValueToPointer(model.EtherType),
IpRange: conversion.StringValueToPointer(model.IpRange),
RemoteSecurityGroupId: conversion.StringValueToPointer(model.RemoteSecurityGroupId),
IcmpParameters: payloadIcmpParameters,
PortRange: payloadPortRange,
Protocol: payloadProtocol,
}, nil
}
func toIcmpParametersPayload(icmpParameters *icmpParametersModel) (*iaas.ICMPParameters, error) {
if icmpParameters == nil {
return nil, nil
}
payloadParams := &iaas.ICMPParameters{}
payloadParams.Code = conversion.Int64ValueToPointer(icmpParameters.Code)
payloadParams.Type = conversion.Int64ValueToPointer(icmpParameters.Type)
return payloadParams, nil
}
func toPortRangePayload(portRange *portRangeModel) (*iaas.PortRange, error) {
if portRange == nil {
return nil, nil
}
payloadPortRange := &iaas.PortRange{}
payloadPortRange.Max = conversion.Int64ValueToPointer(portRange.Max)
payloadPortRange.Min = conversion.Int64ValueToPointer(portRange.Min)
return payloadPortRange, nil
}
func toProtocolPayload(protocol *protocolModel) (*iaas.CreateProtocol, error) {
if protocol == nil {
return nil, nil
}
payloadProtocol := &iaas.CreateProtocol{}
payloadProtocol.String = conversion.StringValueToPointer(protocol.Name)
payloadProtocol.Int64 = conversion.Int64ValueToPointer(protocol.Number)
return payloadProtocol, nil
}

View file

@ -0,0 +1,305 @@
package securitygrouprule
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
var fixtureModelIcmpParameters = types.ObjectValueMust(icmpParametersTypes, map[string]attr.Value{
"code": types.Int64Value(1),
"type": types.Int64Value(2),
})
var fixtureIcmpParameters = iaas.ICMPParameters{
Code: utils.Ptr(int64(1)),
Type: utils.Ptr(int64(2)),
}
var fixtureModelPortRange = types.ObjectValueMust(portRangeTypes, map[string]attr.Value{
"max": types.Int64Value(2),
"min": types.Int64Value(1),
})
var fixturePortRange = iaas.PortRange{
Max: utils.Ptr(int64(2)),
Min: utils.Ptr(int64(1)),
}
var fixtureModelProtocol = types.ObjectValueMust(protocolTypes, map[string]attr.Value{
"name": types.StringValue("name"),
"number": types.Int64Value(1),
})
var fixtureProtocol = iaas.Protocol{
Name: utils.Ptr("name"),
Number: utils.Ptr(int64(1)),
}
var fixtureModelCreateProtocol = types.ObjectValueMust(protocolTypes, map[string]attr.Value{
"name": types.StringValue("name"),
"number": types.Int64Null(),
})
var fixtureCreateProtocol = iaas.CreateProtocol{
String: utils.Ptr("name"),
}
func TestMapFields(t *testing.T) {
tests := []struct {
description string
state Model
input *iaas.SecurityGroupRule
expected Model
isValid bool
}{
{
"default_values",
Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
},
&iaas.SecurityGroupRule{
Id: utils.Ptr("sgrid"),
},
Model{
Id: types.StringValue("pid,sgid,sgrid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Direction: types.StringNull(),
Description: types.StringNull(),
EtherType: types.StringNull(),
IpRange: types.StringNull(),
RemoteSecurityGroupId: types.StringNull(),
IcmpParameters: types.ObjectNull(icmpParametersTypes),
PortRange: types.ObjectNull(portRangeTypes),
Protocol: types.ObjectNull(protocolTypes),
},
true,
},
{
"simple_values",
Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
},
&iaas.SecurityGroupRule{
Id: utils.Ptr("sgrid"),
Description: utils.Ptr("desc"),
Direction: utils.Ptr("ingress"),
Ethertype: utils.Ptr("ether"),
IpRange: utils.Ptr("iprange"),
RemoteSecurityGroupId: utils.Ptr("remote"),
IcmpParameters: &fixtureIcmpParameters,
PortRange: &fixturePortRange,
Protocol: &fixtureProtocol,
},
Model{
Id: types.StringValue("pid,sgid,sgrid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Direction: types.StringValue("ingress"),
Description: types.StringValue("desc"),
EtherType: types.StringValue("ether"),
IpRange: types.StringValue("iprange"),
RemoteSecurityGroupId: types.StringValue("remote"),
IcmpParameters: fixtureModelIcmpParameters,
PortRange: fixtureModelPortRange,
Protocol: fixtureModelProtocol,
},
true,
},
{
"protocol_only_with_name",
Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Protocol: types.ObjectValueMust(protocolTypes, map[string]attr.Value{
"name": types.StringValue("name"),
"number": types.Int64Null(),
}),
},
&iaas.SecurityGroupRule{
Id: utils.Ptr("sgrid"),
Protocol: &fixtureProtocol,
},
Model{
Id: types.StringValue("pid,sgid,sgrid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Direction: types.StringNull(),
Description: types.StringNull(),
EtherType: types.StringNull(),
IpRange: types.StringNull(),
RemoteSecurityGroupId: types.StringNull(),
IcmpParameters: types.ObjectNull(icmpParametersTypes),
PortRange: types.ObjectNull(portRangeTypes),
Protocol: fixtureModelProtocol,
},
true,
},
{
"protocol_only_with_number",
Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Protocol: types.ObjectValueMust(protocolTypes, map[string]attr.Value{
"name": types.StringNull(),
"number": types.Int64Value(1),
}),
},
&iaas.SecurityGroupRule{
Id: utils.Ptr("sgrid"),
Protocol: &fixtureProtocol,
},
Model{
Id: types.StringValue("pid,sgid,sgrid"),
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
SecurityGroupRuleId: types.StringValue("sgrid"),
Direction: types.StringNull(),
Description: types.StringNull(),
EtherType: types.StringNull(),
IpRange: types.StringNull(),
RemoteSecurityGroupId: types.StringNull(),
IcmpParameters: types.ObjectNull(icmpParametersTypes),
PortRange: types.ObjectNull(portRangeTypes),
Protocol: fixtureModelProtocol,
},
true,
},
{
"response_nil_fail",
Model{},
nil,
Model{},
false,
},
{
"no_resource_id",
Model{
ProjectId: types.StringValue("pid"),
SecurityGroupId: types.StringValue("sgid"),
},
&iaas.SecurityGroupRule{},
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(tt.input, &tt.state)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(tt.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.CreateSecurityGroupRulePayload
isValid bool
}{
{
"default_values",
&Model{},
&iaas.CreateSecurityGroupRulePayload{},
true,
},
{
"default_ok",
&Model{
Description: types.StringValue("desc"),
Direction: types.StringValue("ingress"),
IcmpParameters: fixtureModelIcmpParameters,
PortRange: fixtureModelPortRange,
Protocol: fixtureModelCreateProtocol,
},
&iaas.CreateSecurityGroupRulePayload{
Description: utils.Ptr("desc"),
Direction: utils.Ptr("ingress"),
IcmpParameters: &fixtureIcmpParameters,
PortRange: &fixturePortRange,
Protocol: &fixtureCreateProtocol,
},
true,
},
{
"nil_model",
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
var icmpParameters *icmpParametersModel
var portRange *portRangeModel
var protocol *protocolModel
if tt.input != nil {
if !(tt.input.IcmpParameters.IsNull() || tt.input.IcmpParameters.IsUnknown()) {
icmpParameters = &icmpParametersModel{}
diags := tt.input.IcmpParameters.As(context.Background(), icmpParameters, basetypes.ObjectAsOptions{})
if diags.HasError() {
t.Fatalf("Error converting icmp parameters: %v", diags.Errors())
}
}
if !(tt.input.PortRange.IsNull() || tt.input.PortRange.IsUnknown()) {
portRange = &portRangeModel{}
diags := tt.input.PortRange.As(context.Background(), portRange, basetypes.ObjectAsOptions{})
if diags.HasError() {
t.Fatalf("Error converting port range: %v", diags.Errors())
}
}
if !(tt.input.Protocol.IsNull() || tt.input.Protocol.IsUnknown()) {
protocol = &protocolModel{}
diags := tt.input.Protocol.As(context.Background(), protocol, basetypes.ObjectAsOptions{})
if diags.HasError() {
t.Fatalf("Error converting protocol: %v", diags.Errors())
}
}
}
output, err := toCreatePayload(tt.input, icmpParameters, portRange, protocol)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -0,0 +1,167 @@
package server
const markdownDescription = `
Server resource schema. Must have a region specified in the provider configuration.` + "\n" + `
~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources.
## Example Usage` + "\n" + `
### Boot from volume` + "\n" +
"```terraform" + `
resource "stackit_server" "boot-from-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
availability_zone = "eu01-1"
machine_type = "g1.1"
keypair_name = "example-keypair"
}
` + "\n```" + `
### Boot from existing volume` + "\n" +
"```terraform" + `
resource "stackit_volume" "example-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 12
source = {
type = "image"
id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-volume"
availability_zone = "eu01-1"
}
resource "stackit_server" "boot-from-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
source_type = "volume"
source_id = stackit_volume.example-volume.volume_id
}
availability_zone = "eu01-1"
machine_type = "g1.1"
keypair_name = "example-keypair"
}
` + "\n```" + `
### Network setup` + "\n" +
"```terraform" + `
resource "stackit_server" "server-with-network" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
machine_type = "g1.1"
keypair_name = "example-keypair"
}
resource "stackit_network" "network" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-network"
nameservers = ["192.0.2.0", "198.51.100.0", "203.0.113.0"]
ipv4_prefix_length = 24
}
resource "stackit_security_group" "sec-group" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-security-group"
stateful = true
}
resource "stackit_security_group_rule" "rule" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
security_group_id = stackit_security_group.sec-group.security_group_id
direction = "ingress"
ether_type = "IPv4"
}
resource "stackit_network_interface" "nic" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_id = stackit_network.network.network_id
security_group_ids = [stackit_security_group.sec-group.security_group_id]
}
resource "stackit_public_ip" "public-ip" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
network_interface_id = stackit_network_interface.nic.network_interface_id
}
resource "stackit_server_network_interface_attach" "nic-attachment" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = stackit_server.server-with-network.server_id
network_interface_id = stackit_network_interface.nic.network_interface_id
}
` + "\n```" + `
### Server with attached volume` + "\n" +
"```terraform" + `
resource "stackit_volume" "example-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size = 12
source = {
type = "image"
id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-volume"
availability_zone = "eu01-1"
}
resource "stackit_server" "server-with-volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
name = "example-server"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
availability_zone = "eu01-1"
machine_type = "g1.1"
keypair_name = "example-keypair"
}
resource "stackit_server_volume_attach" "attach_volume" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
server_id = stackit_server.server-with-volume.server_id
volume_id = stackit_volume.example-volume.volume_id
}
` + "\n```" + `
### Server with user data (cloud-init)` + "\n" +
"```terraform" + `
resource "stackit_server" "user-data" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-server"
machine_type = "g1.1"
keypair_name = "example-keypair"
user_data = "#!/bin/bash\n/bin/su"
}
resource "stackit_server" "user-data-from-file" {
project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
boot_volume = {
size = 64
source_type = "image"
source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
name = "example-server"
machine_type = "g1.1"
keypair_name = "example-keypair"
user_data = file("${path.module}/cloud-init.yaml")
}
` + "\n```"

View file

@ -0,0 +1,224 @@
package server
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// serverDataSourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var serverDataSourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &serverDataSource{}
)
// NewServerDataSource is a helper function to simplify the provider implementation.
func NewServerDataSource() datasource.DataSource {
return &serverDataSource{}
}
// serverDataSource is the data source implementation.
type serverDataSource struct {
client *iaas.APIClient
}
// Metadata returns the data source type name.
func (d *serverDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_server"
}
func (d *serverDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
var apiClient *iaas.APIClient
var err error
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !serverDataSourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_server", "data source")
if resp.Diagnostics.HasError() {
return
}
serverDataSourceBetaCheckDone = true
}
if providerData.IaaSCustomEndpoint != "" {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err))
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the datasource.
func (r *serverDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Server datasource schema. Must have a `region` specified in the provider configuration."),
Description: "Server datasource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`server_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the server is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"server_id": schema.StringAttribute{
Description: "The server ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the server.",
Computed: true,
},
"machine_type": schema.StringAttribute{
MarkdownDescription: "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)",
Computed: true,
},
"availability_zone": schema.StringAttribute{
Description: "The availability zone of the server.",
Computed: true,
},
"boot_volume": schema.SingleNestedAttribute{
Description: "The boot volume for the server",
Computed: true,
Attributes: map[string]schema.Attribute{
"performance_class": schema.StringAttribute{
Description: "The performance class of the server.",
Computed: true,
},
"size": schema.Int64Attribute{
Description: "The size of the boot volume in GB.",
Computed: true,
},
"type": schema.StringAttribute{
Description: "The type of the source. " + utils.SupportedValuesDocumentation(supportedSourceTypes),
Computed: true,
},
"id": schema.StringAttribute{
Description: "The ID of the source, either image ID or volume ID",
Computed: true,
},
},
},
"image_id": schema.StringAttribute{
Description: "The image ID to be used for an ephemeral disk on the server.",
Computed: true,
},
"keypair_name": schema.StringAttribute{
Description: "The name of the keypair used during server creation.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
"affinity_group": schema.StringAttribute{
Description: "The affinity group the server is assigned to.",
Computed: true,
},
"user_data": schema.StringAttribute{
Description: "User data that is passed via cloud-init to the server.",
Computed: true,
},
"created_at": schema.StringAttribute{
Description: "Date-time when the server was created",
Computed: true,
},
"launched_at": schema.StringAttribute{
Description: "Date-time when the server was launched",
Computed: true,
},
"updated_at": schema.StringAttribute{
Description: "Date-time when the server was updated",
Computed: true,
},
},
}
}
// // Read refreshes the Terraform state with the latest data.
func (r *serverDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
serverResp, err := r.client.GetServer(ctx, projectId, serverId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, serverResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "server read")
}

View file

@ -0,0 +1,682 @@
package server
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// resourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var resourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &serverResource{}
_ resource.ResourceWithConfigure = &serverResource{}
_ resource.ResourceWithImportState = &serverResource{}
supportedSourceTypes = []string{"volume", "image"}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
ServerId types.String `tfsdk:"server_id"`
MachineType types.String `tfsdk:"machine_type"`
Name types.String `tfsdk:"name"`
AvailabilityZone types.String `tfsdk:"availability_zone"`
BootVolume types.Object `tfsdk:"boot_volume"`
ImageId types.String `tfsdk:"image_id"`
KeypairName types.String `tfsdk:"keypair_name"`
Labels types.Map `tfsdk:"labels"`
AffinityGroup types.String `tfsdk:"affinity_group"`
UserData types.String `tfsdk:"user_data"`
CreatedAt types.String `tfsdk:"created_at"`
LaunchedAt types.String `tfsdk:"launched_at"`
UpdatedAt types.String `tfsdk:"updated_at"`
}
// Struct corresponding to Model.BootVolume
type bootVolumeModel struct {
PerformanceClass types.String `tfsdk:"performance_class"`
Size types.Int64 `tfsdk:"size"`
SourceType types.String `tfsdk:"source_type"`
SourceId types.String `tfsdk:"source_id"`
}
// Types corresponding to bootVolumeModel
var bootVolumeTypes = map[string]attr.Type{
"performance_class": basetypes.StringType{},
"size": basetypes.Int64Type{},
"source_type": basetypes.StringType{},
"source_id": basetypes.StringType{},
}
// NewServerResource is a helper function to simplify the provider implementation.
func NewServerResource() resource.Resource {
return &serverResource{}
}
// serverResource is the resource implementation.
type serverResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *serverResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_server"
}
// ConfigValidators validates the resource configuration
func (r *serverResource) ConfigValidators(_ context.Context) []resource.ConfigValidator {
return []resource.ConfigValidator{
resourcevalidator.AtLeastOneOf(
path.MatchRoot("image_id"),
path.MatchRoot("boot_volume"),
),
resourcevalidator.Conflicting(
path.MatchRoot("image_id"),
path.MatchRoot("boot_volume"),
),
}
}
// Configure adds the provider configured client to the resource.
func (r *serverResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !resourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_server", "resource")
if resp.Diagnostics.HasError() {
return
}
resourceBetaCheckDone = true
}
var apiClient *iaas.APIClient
var err error
if providerData.IaaSCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint)
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *serverResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: markdownDescription,
Description: "Server resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`server_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the server is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"server_id": schema.StringAttribute{
Description: "The server ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the server.",
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"machine_type": schema.StringAttribute{
MarkdownDescription: "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)",
Required: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"availability_zone": schema.StringAttribute{
Description: "The availability zone of the server.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Optional: true,
Computed: true,
},
"boot_volume": schema.SingleNestedAttribute{
Description: "The boot volume for the server",
Optional: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.RequiresReplace(),
},
Attributes: map[string]schema.Attribute{
"performance_class": schema.StringAttribute{
Description: "The performance class of the server.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"size": schema.Int64Attribute{
Description: "The size of the boot volume in GB. Must be provided when `source_type` is `image`.",
Optional: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
},
"source_type": schema.StringAttribute{
Description: "The type of the source. " + utils.SupportedValuesDocumentation(supportedSourceTypes),
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"source_id": schema.StringAttribute{
Description: "The ID of the source, either image ID or volume ID",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
},
},
"image_id": schema.StringAttribute{
Description: "The image ID to be used for an ephemeral disk on the server.",
Optional: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"keypair_name": schema.StringAttribute{
Description: "The name of the keypair used during server creation.",
Optional: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
"affinity_group": schema.StringAttribute{
Description: "The affinity group the server is assigned to.",
Optional: true,
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(36),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`),
"must match expression"),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"user_data": schema.StringAttribute{
Description: "User data that is passed via cloud-init to the server.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"created_at": schema.StringAttribute{
Description: "Date-time when the server was created",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"launched_at": schema.StringAttribute{
Description: "Date-time when the server was launched",
Computed: true,
},
"updated_at": schema.StringAttribute{
Description: "Date-time when the server was updated",
Computed: true,
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new server
server, err := r.client.CreateServer(ctx, projectId).CreateServerPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("Calling API: %v", err))
return
}
serverId := *server.Id
server, err = wait.CreateServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("server creation waiting: %v", err))
return
}
ctx = tflog.SetField(ctx, "server_id", serverId)
// Map response body to schema
err = mapFields(ctx, server, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Server created")
}
// // Read refreshes the Terraform state with the latest data.
func (r *serverResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
serverResp, err := r.client.GetServer(ctx, projectId, serverId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, serverResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "server read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing server
updatedServer, err := r.client.UpdateServer(ctx, projectId, serverId).UpdateServerPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Calling API: %v", err))
return
}
// Update machine type
modelMachineType := conversion.StringValueToPointer(model.MachineType)
if modelMachineType != nil && updatedServer.MachineType != nil && *modelMachineType != *updatedServer.MachineType {
payload := iaas.ResizeServerPayload{
MachineType: modelMachineType,
}
err := r.client.ResizeServer(ctx, projectId, serverId).ResizeServerPayload(payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Resizing the server, calling API: %v", err))
}
_, err = wait.ResizeServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("server resize waiting: %v", err))
return
}
// Update server model because the API doesn't return a server object as response
updatedServer.MachineType = modelMachineType
}
err = mapFields(ctx, updatedServer, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "server updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *serverResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
// Delete existing server
err := r.client.DeleteServer(ctx, projectId, serverId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting server", fmt.Sprintf("Calling API: %v", err))
return
}
_, err = wait.DeleteServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting server", fmt.Sprintf("server deletion waiting: %v", err))
return
}
tflog.Info(ctx, "server deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,server_id
func (r *serverResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing server",
fmt.Sprintf("Expected import identifier with format: [project_id],[server_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
serverId := idParts[1]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...)
tflog.Info(ctx, "server state imported")
}
func mapFields(ctx context.Context, serverResp *iaas.Server, model *Model) error {
if serverResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var serverId string
if model.ServerId.ValueString() != "" {
serverId = model.ServerId.ValueString()
} else if serverResp.Id != nil {
serverId = *serverResp.Id
} else {
return fmt.Errorf("Server id not present")
}
idParts := []string{
model.ProjectId.ValueString(),
serverId,
}
model.Id = types.StringValue(
strings.Join(idParts, core.Separator),
)
labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{})
if diags.HasError() {
return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags))
}
if serverResp.Labels != nil && len(*serverResp.Labels) != 0 {
var diags diag.Diagnostics
labels, diags = types.MapValueFrom(ctx, types.StringType, *serverResp.Labels)
if diags.HasError() {
return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags))
}
} else if model.Labels.IsNull() {
labels = types.MapNull(types.StringType)
}
var createdAt basetypes.StringValue
if serverResp.CreatedAt != nil {
createdAtValue := *serverResp.CreatedAt
createdAt = types.StringValue(createdAtValue.Format(time.RFC3339))
}
var updatedAt basetypes.StringValue
if serverResp.UpdatedAt != nil {
updatedAtValue := *serverResp.UpdatedAt
updatedAt = types.StringValue(updatedAtValue.Format(time.RFC3339))
}
var launchedAt basetypes.StringValue
if serverResp.LaunchedAt != nil {
launchedAtValue := *serverResp.LaunchedAt
launchedAt = types.StringValue(launchedAtValue.Format(time.RFC3339))
}
model.ServerId = types.StringValue(serverId)
model.MachineType = types.StringPointerValue(serverResp.MachineType)
model.AvailabilityZone = types.StringPointerValue(serverResp.AvailabilityZone)
model.Name = types.StringPointerValue(serverResp.Name)
model.Labels = labels
model.ImageId = types.StringPointerValue(serverResp.ImageId)
model.KeypairName = types.StringPointerValue(serverResp.KeypairName)
model.AffinityGroup = types.StringPointerValue(serverResp.AffinityGroup)
model.CreatedAt = createdAt
model.UpdatedAt = updatedAt
model.LaunchedAt = launchedAt
return nil
}
func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateServerPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
var bootVolume = &bootVolumeModel{}
if !(model.BootVolume.IsNull() || model.BootVolume.IsUnknown()) {
diags := model.BootVolume.As(ctx, bootVolume, basetypes.ObjectAsOptions{})
if diags.HasError() {
return nil, fmt.Errorf("convert boot volume object to struct: %w", core.DiagsToError(diags))
}
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
var bootVolumePayload *iaas.CreateServerPayloadBootVolume
if !bootVolume.SourceId.IsNull() && !bootVolume.SourceType.IsNull() {
bootVolumePayload = &iaas.CreateServerPayloadBootVolume{
PerformanceClass: conversion.StringValueToPointer(bootVolume.PerformanceClass),
Size: conversion.Int64ValueToPointer(bootVolume.Size),
Source: &iaas.BootVolumeSource{
Id: conversion.StringValueToPointer(bootVolume.SourceId),
Type: conversion.StringValueToPointer(bootVolume.SourceType),
},
}
}
var userData *string
if !model.UserData.IsNull() && !model.UserData.IsUnknown() {
encodedUserData := base64.StdEncoding.EncodeToString([]byte(model.UserData.ValueString()))
userData = &encodedUserData
}
return &iaas.CreateServerPayload{
AvailabilityZone: conversion.StringValueToPointer(model.AvailabilityZone),
BootVolume: bootVolumePayload,
ImageId: conversion.StringValueToPointer(model.ImageId),
KeypairName: conversion.StringValueToPointer(model.KeypairName),
Labels: &labels,
Name: conversion.StringValueToPointer(model.Name),
MachineType: conversion.StringValueToPointer(model.MachineType),
UserData: userData,
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateServerPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.UpdateServerPayload{
Name: conversion.StringValueToPointer(model.Name),
Labels: &labels,
}, nil
}

View file

@ -0,0 +1,269 @@
package server
import (
"context"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
const (
userData = "user_data"
base64EncodedUserData = "dXNlcl9kYXRh"
testTimestampValue = "2006-01-02T15:04:05Z"
)
func testTimestamp() time.Time {
timestamp, _ := time.Parse(time.RFC3339, testTimestampValue)
return timestamp
}
func TestMapFields(t *testing.T) {
tests := []struct {
description string
state Model
input *iaas.Server
expected Model
isValid bool
}{
{
"default_values",
Model{
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
},
&iaas.Server{
Id: utils.Ptr("sid"),
},
Model{
Id: types.StringValue("pid,sid"),
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Name: types.StringNull(),
AvailabilityZone: types.StringNull(),
Labels: types.MapNull(types.StringType),
ImageId: types.StringNull(),
KeypairName: types.StringNull(),
AffinityGroup: types.StringNull(),
UserData: types.StringNull(),
CreatedAt: types.StringNull(),
UpdatedAt: types.StringNull(),
LaunchedAt: types.StringNull(),
},
true,
},
{
"simple_values",
Model{
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
},
&iaas.Server{
Id: utils.Ptr("sid"),
Name: utils.Ptr("name"),
AvailabilityZone: utils.Ptr("zone"),
Labels: &map[string]interface{}{
"key": "value",
},
ImageId: utils.Ptr("image_id"),
KeypairName: utils.Ptr("keypair_name"),
AffinityGroup: utils.Ptr("group_id"),
CreatedAt: utils.Ptr(testTimestamp()),
UpdatedAt: utils.Ptr(testTimestamp()),
LaunchedAt: utils.Ptr(testTimestamp()),
},
Model{
Id: types.StringValue("pid,sid"),
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Name: types.StringValue("name"),
AvailabilityZone: types.StringValue("zone"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
ImageId: types.StringValue("image_id"),
KeypairName: types.StringValue("keypair_name"),
AffinityGroup: types.StringValue("group_id"),
CreatedAt: types.StringValue(testTimestampValue),
UpdatedAt: types.StringValue(testTimestampValue),
LaunchedAt: types.StringValue(testTimestampValue),
},
true,
},
{
"empty_labels",
Model{
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
&iaas.Server{
Id: utils.Ptr("sid"),
},
Model{
Id: types.StringValue("pid,sid"),
ProjectId: types.StringValue("pid"),
ServerId: types.StringValue("sid"),
Name: types.StringNull(),
AvailabilityZone: types.StringNull(),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
ImageId: types.StringNull(),
KeypairName: types.StringNull(),
AffinityGroup: types.StringNull(),
UserData: types.StringNull(),
CreatedAt: types.StringNull(),
UpdatedAt: types.StringNull(),
LaunchedAt: types.StringNull(),
},
true,
},
{
"response_nil_fail",
Model{},
nil,
Model{},
false,
},
{
"no_resource_id",
Model{
ProjectId: types.StringValue("pid"),
},
&iaas.Server{},
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.input, &tt.state)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(tt.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.CreateServerPayload
isValid bool
}{
{
"ok",
&Model{
Name: types.StringValue("name"),
AvailabilityZone: types.StringValue("zone"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
BootVolume: types.ObjectValueMust(bootVolumeTypes, map[string]attr.Value{
"performance_class": types.StringValue("class"),
"size": types.Int64Value(1),
"source_type": types.StringValue("type"),
"source_id": types.StringValue("id"),
}),
ImageId: types.StringValue("image"),
KeypairName: types.StringValue("keypair"),
MachineType: types.StringValue("machine_type"),
UserData: types.StringValue(userData),
},
&iaas.CreateServerPayload{
Name: utils.Ptr("name"),
AvailabilityZone: utils.Ptr("zone"),
Labels: &map[string]interface{}{
"key": "value",
},
BootVolume: &iaas.CreateServerPayloadBootVolume{
PerformanceClass: utils.Ptr("class"),
Size: utils.Ptr(int64(1)),
Source: &iaas.BootVolumeSource{
Type: utils.Ptr("type"),
Id: utils.Ptr("id"),
},
},
ImageId: utils.Ptr("image"),
KeypairName: utils.Ptr("keypair"),
MachineType: utils.Ptr("machine_type"),
UserData: utils.Ptr(base64EncodedUserData),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.UpdateServerPayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
},
&iaas.UpdateServerPayload{
Name: utils.Ptr("name"),
Labels: &map[string]interface{}{
"key": "value",
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -0,0 +1,299 @@
package serviceaccountattach
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// resourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var resourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &networkInterfaceAttachResource{}
_ resource.ResourceWithConfigure = &networkInterfaceAttachResource{}
_ resource.ResourceWithImportState = &networkInterfaceAttachResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
ServerId types.String `tfsdk:"server_id"`
ServiceAccountEmail types.String `tfsdk:"service_account_email"`
}
// NewServiceAccountAttachResource is a helper function to simplify the provider implementation.
func NewServiceAccountAttachResource() resource.Resource {
return &networkInterfaceAttachResource{}
}
// networkInterfaceAttachResource is the resource implementation.
type networkInterfaceAttachResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *networkInterfaceAttachResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_server_service_account_attach"
}
// Configure adds the provider configured client to the resource.
func (r *networkInterfaceAttachResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !resourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_server_service_account_attach", "resource")
if resp.Diagnostics.HasError() {
return
}
resourceBetaCheckDone = true
}
var apiClient *iaas.APIClient
var err error
if providerData.IaaSCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint)
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *networkInterfaceAttachResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Service account attachment resource schema. Attaches a service account to a server. Must have a `region` specified in the provider configuration."),
Description: "Service account attachment resource schema. Attaches a service account to a server. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`server_id`,`service_account_email`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the service account attachment is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"server_id": schema.StringAttribute{
Description: "The server ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"service_account_email": schema.StringAttribute{
Description: "The service account email.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *networkInterfaceAttachResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "server_id", serverId)
serviceAccountEmail := model.ServiceAccountEmail.ValueString()
ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail)
// Create new service account attachment
_, err := r.client.AddServiceAccountToServer(ctx, projectId, serverId, serviceAccountEmail).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching service account to server", fmt.Sprintf("Calling API: %v", err))
return
}
idParts := []string{
projectId,
serverId,
serviceAccountEmail,
}
model.Id = types.StringValue(
strings.Join(idParts, core.Separator),
)
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Service account attachment created")
}
// Read refreshes the Terraform state with the latest data.
func (r *networkInterfaceAttachResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "server_id", serverId)
serviceAccountEmail := model.ServiceAccountEmail.ValueString()
ctx = tflog.SetField(ctx, "service_account_email", serviceAccountEmail)
serviceAccounts, err := r.client.ListServerServiceAccounts(ctx, projectId, serverId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account attachment", fmt.Sprintf("Calling API: %v", err))
return
}
if serviceAccounts == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading service account attachment", "List of service accounts attached to the server is nil")
return
}
if serviceAccounts.Items != nil {
for _, mail := range *serviceAccounts.Items {
if mail != serviceAccountEmail {
continue
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Service account attachment read")
return
}
}
// no matching service account was found, the attachment no longer exists
resp.State.RemoveResource(ctx)
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *networkInterfaceAttachResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update is not supported, all fields require replace
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *networkInterfaceAttachResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "server_id", serverId)
service_accountId := model.ServiceAccountEmail.ValueString()
ctx = tflog.SetField(ctx, "service_account_email", service_accountId)
// Remove service_account from server
_, err := r.client.RemoveServiceAccountFromServer(ctx, projectId, serverId, service_accountId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing service account from server", fmt.Sprintf("Calling API: %v", err))
return
}
tflog.Info(ctx, "Service account attachment deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,server_id
func (r *networkInterfaceAttachResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing service_account attachment",
fmt.Sprintf("Expected import identifier with format: [project_id],[server_id],[service_account_email] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
serverId := idParts[1]
service_accountId := idParts[2]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "service_account_email", service_accountId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("service_account_email"), service_accountId)...)
tflog.Info(ctx, "Service account attachment state imported")
}

View file

@ -0,0 +1,202 @@
package volume
import (
"context"
"fmt"
"net/http"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// volumeDataSourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var volumeDataSourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ datasource.DataSource = &volumeDataSource{}
)
// NewVolumeDataSource is a helper function to simplify the provider implementation.
func NewVolumeDataSource() datasource.DataSource {
return &volumeDataSource{}
}
// volumeDataSource is the data source implementation.
type volumeDataSource struct {
client *iaas.APIClient
}
// Metadata returns the data source type name.
func (d *volumeDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_volume"
}
func (d *volumeDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
var apiClient *iaas.APIClient
var err error
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !volumeDataSourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_volume", "data source")
if resp.Diagnostics.HasError() {
return
}
volumeDataSourceBetaCheckDone = true
}
if providerData.IaaSCustomEndpoint != "" {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err))
return
}
d.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *volumeDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Volume resource schema. Must have a `region` specified in the provider configuration."),
Description: "Volume resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`volume_id`\".",
Computed: true,
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the volume is associated.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"volume_id": schema.StringAttribute{
Description: "The volume ID.",
Required: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"server_id": schema.StringAttribute{
Description: "The server ID of the server to which the volume is attached to.",
Computed: true,
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the volume.",
Computed: true,
},
"description": schema.StringAttribute{
Description: "The description of the volume.",
Computed: true,
},
"availability_zone": schema.StringAttribute{
Description: "The availability zone of the volume.",
Computed: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Computed: true,
},
"performance_class": schema.StringAttribute{
Description: "The performance class of the volume.",
Computed: true,
},
"size": schema.Int64Attribute{
Description: "The size of the volume in GB. It can only be updated to a larger value than the current size",
Computed: true,
},
"source": schema.SingleNestedAttribute{
Description: "The source of the volume. It can be either a volume, an image, a snapshot or a backup",
Computed: true,
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Description: "The type of the source. " + utils.SupportedValuesDocumentation(SupportedSourceTypes),
Computed: true,
},
"id": schema.StringAttribute{
Description: "The ID of the source, e.g. image ID",
Computed: true,
},
},
},
},
}
}
// Read refreshes the Terraform state with the latest data.
func (d *volumeDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.Config.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
volumeId := model.VolumeId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "volume_id", volumeId)
volumeResp, err := d.client.GetVolume(ctx, projectId, volumeId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", fmt.Sprintf("Calling API: %v", err))
return
}
err = mapFields(ctx, volumeResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "volume read")
}

View file

@ -0,0 +1,608 @@
package volume
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// resourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var resourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &volumeResource{}
_ resource.ResourceWithConfigure = &volumeResource{}
_ resource.ResourceWithImportState = &volumeResource{}
SupportedSourceTypes = []string{"volume", "image", "snapshot", "backup"}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
VolumeId types.String `tfsdk:"volume_id"`
Name types.String `tfsdk:"name"`
AvailabilityZone types.String `tfsdk:"availability_zone"`
Labels types.Map `tfsdk:"labels"`
Description types.String `tfsdk:"description"`
PerformanceClass types.String `tfsdk:"performance_class"`
Size types.Int64 `tfsdk:"size"`
ServerId types.String `tfsdk:"server_id"`
Source types.Object `tfsdk:"source"`
}
// Struct corresponding to Model.Source
type sourceModel struct {
Type types.String `tfsdk:"type"`
Id types.String `tfsdk:"id"`
}
// Types corresponding to sourceModel
var sourceTypes = map[string]attr.Type{
"type": basetypes.StringType{},
"id": basetypes.StringType{},
}
// NewVolumeResource is a helper function to simplify the provider implementation.
func NewVolumeResource() resource.Resource {
return &volumeResource{}
}
// volumeResource is the resource implementation.
type volumeResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *volumeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_volume"
}
// ConfigValidators validates the resource configuration
func (r *volumeResource) ConfigValidators(_ context.Context) []resource.ConfigValidator {
return []resource.ConfigValidator{
resourcevalidator.AtLeastOneOf(
path.MatchRoot("source"),
path.MatchRoot("size"),
),
}
}
// Configure adds the provider configured client to the resource.
func (r *volumeResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !resourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_volume", "resource")
if resp.Diagnostics.HasError() {
return
}
resourceBetaCheckDone = true
}
var apiClient *iaas.APIClient
var err error
if providerData.IaaSCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint)
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *volumeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Volume resource schema. Must have a `region` specified in the provider configuration."),
Description: "Volume resource schema. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`volume_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the volume is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"volume_id": schema.StringAttribute{
Description: "The volume ID.",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"server_id": schema.StringAttribute{
Description: "The server ID of the server to which the volume is attached to.",
Computed: true,
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: "The name of the volume.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"description": schema.StringAttribute{
Description: "The description of the volume.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(127),
},
},
"availability_zone": schema.StringAttribute{
Description: "The availability zone of the volume.",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Required: true,
},
"labels": schema.MapAttribute{
Description: "Labels are key-value string pairs which can be attached to a resource container",
ElementType: types.StringType,
Optional: true,
},
"performance_class": schema.StringAttribute{
Description: "The performance class of the volume.",
Optional: true,
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(63),
stringvalidator.RegexMatches(
regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`),
"must match expression"),
},
},
"size": schema.Int64Attribute{
Description: "The size of the volume in GB. It can only be updated to a larger value than the current size. Either `size` or `source` must be provided",
Optional: true,
},
"source": schema.SingleNestedAttribute{
Description: "The source of the volume. It can be either a volume, an image, a snapshot or a backup. Either `size` or `source` must be provided",
Optional: true,
PlanModifiers: []planmodifier.Object{
objectplanmodifier.RequiresReplace(),
},
Attributes: map[string]schema.Attribute{
"type": schema.StringAttribute{
Description: "The type of the source. " + utils.SupportedValuesDocumentation(SupportedSourceTypes),
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"id": schema.StringAttribute{
Description: "The ID of the source, e.g. image ID",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *volumeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
var source = &sourceModel{}
if !(model.Source.IsNull() || model.Source.IsUnknown()) {
diags = model.Source.As(ctx, source, basetypes.ObjectAsOptions{})
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Generate API request body from model
payload, err := toCreatePayload(ctx, &model, source)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Create new volume
volume, err := r.client.CreateVolume(ctx, projectId).CreateVolumePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Calling API: %v", err))
return
}
volumeId := *volume.Id
volume, err = wait.CreateVolumeWaitHandler(ctx, r.client, projectId, volumeId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("volume creation waiting: %v", err))
return
}
ctx = tflog.SetField(ctx, "volume_id", volumeId)
// Map response body to schema
err = mapFields(ctx, volume, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Volume created")
}
// Read refreshes the Terraform state with the latest data.
func (r *volumeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
volumeId := model.VolumeId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "volume_id", volumeId)
volumeResp, err := r.client.GetVolume(ctx, projectId, volumeId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", fmt.Sprintf("Calling API: %v", err))
return
}
// Map response body to schema
err = mapFields(ctx, volumeResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "volume read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *volumeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
volumeId := model.VolumeId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "volume_id", volumeId)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Generate API request body from model
payload, err := toUpdatePayload(ctx, &model, stateModel.Labels)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Creating API payload: %v", err))
return
}
// Update existing volume
updatedVolume, err := r.client.UpdateVolume(ctx, projectId, volumeId).UpdateVolumePayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Calling API: %v", err))
return
}
// Resize existing volume
modelSize := conversion.Int64ValueToPointer(model.Size)
if modelSize != nil && updatedVolume.Size != nil {
// A volume can only be resized to larger values, otherwise an error occurs
if *modelSize < *updatedVolume.Size {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("The new volume size must be larger than the current size (%d GB)", *updatedVolume.Size))
} else if *modelSize > *updatedVolume.Size {
payload := iaas.ResizeVolumePayload{
Size: modelSize,
}
err := r.client.ResizeVolume(ctx, projectId, volumeId).ResizeVolumePayload(payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Resizing the volume, calling API: %v", err))
}
// Update volume model because the API doesn't return a volume object as response
updatedVolume.Size = modelSize
}
}
err = mapFields(ctx, updatedVolume, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Processing API payload: %v", err))
return
}
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "volume updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *volumeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
volumeId := model.VolumeId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "volume_id", volumeId)
// Delete existing volume
err := r.client.DeleteVolume(ctx, projectId, volumeId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting volume", fmt.Sprintf("Calling API: %v", err))
return
}
_, err = wait.DeleteVolumeWaitHandler(ctx, r.client, projectId, volumeId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting volume", fmt.Sprintf("volume deletion waiting: %v", err))
return
}
tflog.Info(ctx, "volume deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,volume_id
func (r *volumeResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing volume",
fmt.Sprintf("Expected import identifier with format: [project_id],[volume_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
volumeId := idParts[1]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "volume_id", volumeId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("volume_id"), volumeId)...)
tflog.Info(ctx, "volume state imported")
}
func mapFields(ctx context.Context, volumeResp *iaas.Volume, model *Model) error {
if volumeResp == nil {
return fmt.Errorf("response input is nil")
}
if model == nil {
return fmt.Errorf("model input is nil")
}
var volumeId string
if model.VolumeId.ValueString() != "" {
volumeId = model.VolumeId.ValueString()
} else if volumeResp.Id != nil {
volumeId = *volumeResp.Id
} else {
return fmt.Errorf("Volume id not present")
}
idParts := []string{
model.ProjectId.ValueString(),
volumeId,
}
model.Id = types.StringValue(
strings.Join(idParts, core.Separator),
)
labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{})
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
if volumeResp.Labels != nil && len(*volumeResp.Labels) != 0 {
var diags diag.Diagnostics
labels, diags = types.MapValueFrom(ctx, types.StringType, *volumeResp.Labels)
if diags.HasError() {
return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags))
}
} else if model.Labels.IsNull() {
labels = types.MapNull(types.StringType)
}
var sourceValues map[string]attr.Value
var sourceObject basetypes.ObjectValue
if volumeResp.Source == nil {
sourceObject = types.ObjectNull(sourceTypes)
} else {
sourceValues = map[string]attr.Value{
"type": types.StringPointerValue(volumeResp.Source.Type),
"id": types.StringPointerValue(volumeResp.Source.Id),
}
sourceObject, diags = types.ObjectValue(sourceTypes, sourceValues)
if diags.HasError() {
return fmt.Errorf("creating source: %w", core.DiagsToError(diags))
}
}
model.VolumeId = types.StringValue(volumeId)
model.AvailabilityZone = types.StringPointerValue(volumeResp.AvailabilityZone)
model.Description = types.StringPointerValue(volumeResp.Description)
model.Name = types.StringPointerValue(volumeResp.Name)
model.Labels = labels
model.PerformanceClass = types.StringPointerValue(volumeResp.PerformanceClass)
model.ServerId = types.StringPointerValue(volumeResp.ServerId)
model.Size = types.Int64PointerValue(volumeResp.Size)
model.Source = sourceObject
return nil
}
func toCreatePayload(ctx context.Context, model *Model, source *sourceModel) (*iaas.CreateVolumePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
var sourcePayload *iaas.VolumeSource
if !source.Id.IsNull() && !source.Type.IsNull() {
sourcePayload = &iaas.VolumeSource{
Id: conversion.StringValueToPointer(source.Id),
Type: conversion.StringValueToPointer(source.Type),
}
}
return &iaas.CreateVolumePayload{
AvailabilityZone: conversion.StringValueToPointer(model.AvailabilityZone),
Description: conversion.StringValueToPointer(model.Description),
Labels: &labels,
Name: conversion.StringValueToPointer(model.Name),
PerformanceClass: conversion.StringValueToPointer(model.PerformanceClass),
Size: conversion.Int64ValueToPointer(model.Size),
Source: sourcePayload,
}, nil
}
func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateVolumePayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels)
if err != nil {
return nil, fmt.Errorf("converting to Go map: %w", err)
}
return &iaas.UpdateVolumePayload{
Description: conversion.StringValueToPointer(model.Description),
Name: conversion.StringValueToPointer(model.Name),
Labels: &labels,
}, nil
}

View file

@ -0,0 +1,253 @@
package volume
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
func TestMapFields(t *testing.T) {
tests := []struct {
description string
state Model
input *iaas.Volume
expected Model
isValid bool
}{
{
"default_values",
Model{
ProjectId: types.StringValue("pid"),
VolumeId: types.StringValue("nid"),
},
&iaas.Volume{
Id: utils.Ptr("nid"),
},
Model{
Id: types.StringValue("pid,nid"),
ProjectId: types.StringValue("pid"),
VolumeId: types.StringValue("nid"),
Name: types.StringNull(),
AvailabilityZone: types.StringNull(),
Labels: types.MapNull(types.StringType),
Description: types.StringNull(),
PerformanceClass: types.StringNull(),
ServerId: types.StringNull(),
Size: types.Int64Null(),
Source: types.ObjectNull(sourceTypes),
},
true,
},
{
"simple_values",
Model{
ProjectId: types.StringValue("pid"),
VolumeId: types.StringValue("nid"),
},
&iaas.Volume{
Id: utils.Ptr("nid"),
Name: utils.Ptr("name"),
AvailabilityZone: utils.Ptr("zone"),
Labels: &map[string]interface{}{
"key": "value",
},
Description: utils.Ptr("desc"),
PerformanceClass: utils.Ptr("class"),
ServerId: utils.Ptr("sid"),
Size: utils.Ptr(int64(1)),
Source: &iaas.VolumeSource{},
},
Model{
Id: types.StringValue("pid,nid"),
ProjectId: types.StringValue("pid"),
VolumeId: types.StringValue("nid"),
Name: types.StringValue("name"),
AvailabilityZone: types.StringValue("zone"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Description: types.StringValue("desc"),
PerformanceClass: types.StringValue("class"),
ServerId: types.StringValue("sid"),
Size: types.Int64Value(1),
Source: types.ObjectValueMust(sourceTypes, map[string]attr.Value{
"type": types.StringNull(),
"id": types.StringNull(),
}),
},
true,
},
{
"empty_labels",
Model{
ProjectId: types.StringValue("pid"),
VolumeId: types.StringValue("nid"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
},
&iaas.Volume{
Id: utils.Ptr("nid"),
},
Model{
Id: types.StringValue("pid,nid"),
ProjectId: types.StringValue("pid"),
VolumeId: types.StringValue("nid"),
Name: types.StringNull(),
AvailabilityZone: types.StringNull(),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}),
Description: types.StringNull(),
PerformanceClass: types.StringNull(),
ServerId: types.StringNull(),
Size: types.Int64Null(),
Source: types.ObjectNull(sourceTypes),
},
true,
},
{
"response_nil_fail",
Model{},
nil,
Model{},
false,
},
{
"no_resource_id",
Model{
ProjectId: types.StringValue("pid"),
},
&iaas.Volume{},
Model{},
false,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
err := mapFields(context.Background(), tt.input, &tt.state)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(tt.state, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToCreatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
source *sourceModel
expected *iaas.CreateVolumePayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
AvailabilityZone: types.StringValue("zone"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Description: types.StringValue("desc"),
PerformanceClass: types.StringValue("class"),
Size: types.Int64Value(1),
Source: types.ObjectValueMust(sourceTypes, map[string]attr.Value{
"type": types.StringNull(),
"id": types.StringNull(),
}),
},
&sourceModel{
Type: types.StringValue("volume"),
Id: types.StringValue("id"),
},
&iaas.CreateVolumePayload{
Name: utils.Ptr("name"),
AvailabilityZone: utils.Ptr("zone"),
Labels: &map[string]interface{}{
"key": "value",
},
Description: utils.Ptr("desc"),
PerformanceClass: utils.Ptr("class"),
Size: utils.Ptr(int64(1)),
Source: &iaas.VolumeSource{
Type: utils.Ptr("volume"),
Id: utils.Ptr("id"),
},
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toCreatePayload(context.Background(), tt.input, tt.source)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}
func TestToUpdatePayload(t *testing.T) {
tests := []struct {
description string
input *Model
expected *iaas.UpdateVolumePayload
isValid bool
}{
{
"default_ok",
&Model{
Name: types.StringValue("name"),
Labels: types.MapValueMust(types.StringType, map[string]attr.Value{
"key": types.StringValue("value"),
}),
Description: types.StringValue("desc"),
},
&iaas.UpdateVolumePayload{
Name: utils.Ptr("name"),
Labels: &map[string]interface{}{
"key": "value",
},
Description: utils.Ptr("desc"),
},
true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType))
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
})
}
}

View file

@ -0,0 +1,305 @@
package volumeattach
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/config"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
"github.com/stackitcloud/stackit-sdk-go/services/iaas"
"github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
)
// resourceBetaCheckDone is used to prevent multiple checks for beta resources.
// This is a workaround for the lack of a global state in the provider and
// needs to exist because the Configure method is called twice.
var resourceBetaCheckDone bool
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &volumeAttachResource{}
_ resource.ResourceWithConfigure = &volumeAttachResource{}
_ resource.ResourceWithImportState = &volumeAttachResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
ProjectId types.String `tfsdk:"project_id"`
ServerId types.String `tfsdk:"server_id"`
VolumeId types.String `tfsdk:"volume_id"`
}
// NewVolumeAttachResource is a helper function to simplify the provider implementation.
func NewVolumeAttachResource() resource.Resource {
return &volumeAttachResource{}
}
// volumeAttachResource is the resource implementation.
type volumeAttachResource struct {
client *iaas.APIClient
}
// Metadata returns the resource type name.
func (r *volumeAttachResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_server_volume_attach"
}
// Configure adds the provider configured client to the resource.
func (r *volumeAttachResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
// Prevent panic if the provider has not been configured.
if req.ProviderData == nil {
return
}
providerData, ok := req.ProviderData.(core.ProviderData)
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData))
return
}
if !resourceBetaCheckDone {
features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_server_volume_attach", "resource")
if resp.Diagnostics.HasError() {
return
}
resourceBetaCheckDone = true
}
var apiClient *iaas.APIClient
var err error
if providerData.IaaSCustomEndpoint != "" {
ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint)
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithEndpoint(providerData.IaaSCustomEndpoint),
)
} else {
apiClient, err = iaas.NewAPIClient(
config.WithCustomAuth(providerData.RoundTripper),
config.WithRegion(providerData.Region),
)
}
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
return
}
r.client = apiClient
tflog.Info(ctx, "iaas client configured")
}
// Schema defines the schema for the resource.
func (r *volumeAttachResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: features.AddBetaDescription("Volume attachment resource schema. Attaches a volume to a server. Must have a `region` specified in the provider configuration."),
Description: "Volume attachment resource schema. Attaches a volume to a server. Must have a `region` specified in the provider configuration.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`server_id`,`volume_id`\".",
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"project_id": schema.StringAttribute{
Description: "STACKIT project ID to which the volume attachment is associated.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"server_id": schema.StringAttribute{
Description: "The server ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"volume_id": schema.StringAttribute{
Description: "The volume ID.",
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *volumeAttachResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from plan
var model Model
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "server_id", serverId)
volumeId := model.VolumeId.ValueString()
ctx = tflog.SetField(ctx, "volume_id", volumeId)
// Create new Volume attachment
payload := iaas.AddVolumeToServerPayload{
DeleteOnTermination: utils.Ptr(false),
}
_, err := r.client.AddVolumeToServer(ctx, projectId, serverId, volumeId).AddVolumeToServerPayload(payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching volume to server", fmt.Sprintf("Calling API: %v", err))
return
}
_, err = wait.AddVolumeToServerWaitHandler(ctx, r.client, projectId, serverId, volumeId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error attaching volume to server", fmt.Sprintf("volume attachment waiting: %v", err))
return
}
idParts := []string{
projectId,
serverId,
volumeId,
}
model.Id = types.StringValue(
strings.Join(idParts, core.Separator),
)
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Volume attachment created")
}
// Read refreshes the Terraform state with the latest data.
func (r *volumeAttachResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "server_id", serverId)
volumeId := model.VolumeId.ValueString()
ctx = tflog.SetField(ctx, "volume_id", volumeId)
_, err := r.client.GetAttachedVolume(ctx, projectId, serverId, volumeId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && oapiErr.StatusCode == http.StatusNotFound {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume attachment", fmt.Sprintf("Calling API: %v", err))
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Volume attachment read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *volumeAttachResource) Update(_ context.Context, _ resource.UpdateRequest, _ *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform
// Update is not supported, all fields require replace
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *volumeAttachResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform
// Retrieve values from state
var model Model
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
projectId := model.ProjectId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
serverId := model.ServerId.ValueString()
ctx = tflog.SetField(ctx, "server_id", serverId)
volumeId := model.VolumeId.ValueString()
ctx = tflog.SetField(ctx, "volume_id", volumeId)
// Remove volume from server
err := r.client.RemoveVolumeFromServer(ctx, projectId, serverId, volumeId).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing volume from server", fmt.Sprintf("Calling API: %v", err))
return
}
_, err = wait.RemoveVolumeFromServerWaitHandler(ctx, r.client, projectId, serverId, volumeId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error removing volume from server", fmt.Sprintf("volume removal waiting: %v", err))
return
}
tflog.Info(ctx, "Volume attachment deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,server_id
func (r *volumeAttachResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
core.LogAndAddError(ctx, &resp.Diagnostics,
"Error importing volume attachment",
fmt.Sprintf("Expected import identifier with format: [project_id],[server_id],[volume_id] Got: %q", req.ID),
)
return
}
projectId := idParts[0]
serverId := idParts[1]
volumeId := idParts[2]
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "server_id", serverId)
ctx = tflog.SetField(ctx, "volume_id", volumeId)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("volume_id"), volumeId)...)
tflog.Info(ctx, "Volume attachment state imported")
}

View file

@ -100,7 +100,7 @@ func (r *userResource) Configure(ctx context.Context, req resource.ConfigureRequ
// Schema defines the schema for the resource.
func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
descriptions := map[string]string{
"main": "[Warning: BETA] SQLServer Flex user resource schema. Must have a `region` specified in the provider configuration.",
"main": "SQLServer Flex user resource schema. Must have a `region` specified in the provider configuration.",
"id": "Terraform's internal resource ID. It is structured as \"`project_id`,`instance_id`,`user_id`\".",
"user_id": "User ID.",
"instance_id": "ID of the SQLServer Flex instance.",

View file

@ -34,6 +34,10 @@ var (
ProjectId = os.Getenv("TF_ACC_PROJECT_ID")
// ServerId is the id of a server used for some tests
ServerId = getenv("TF_ACC_SERVER_ID", "")
// IaaSImageId is the id of an image used for IaaS acceptance tests. Once the stackit_image resource is implemented, we can remove this
IaaSImageId = getenv("TF_ACC_IMAGE_ID", "")
// IaaSNetworkInterfaceId is the id of a network interface used for IaaS acceptance tests. Once acceptance tests are merged, we can remove this
IaaSNetworkInterfaceId = getenv("TF_ACC_NETWORK_INTERFACE_ID", "")
// TestProjectParentContainerID is the container id of the parent resource under which projects are created as part of the resource-manager acceptance tests
TestProjectParentContainerID = os.Getenv("TF_ACC_TEST_PROJECT_PARENT_CONTAINER_ID")
// TestProjectParentContainerID is the uuid of the parent resource under which projects are created as part of the resource-manager acceptance tests
@ -120,6 +124,7 @@ func IaaSProviderConfig() string {
return `
provider "stackit" {
region = "eu01"
enable_beta_resources = true
}`
}
return fmt.Sprintf(`

View file

@ -17,6 +17,15 @@ import (
iaasNetwork "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/network"
iaasNetworkArea "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarea"
iaasNetworkAreaRoute "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarearoute"
iaasNetworkInterface "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkinterface"
iaasNetworkInterfaceAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkinterfaceattach"
iaasPublicIp "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/publicip"
iaasSecurityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygroup"
iaasSecurityGroupRule "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygrouprule"
iaasServer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/server"
iaasServiceAccountAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/serviceaccountattach"
iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume"
iaasVolumeAttach "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volumeattach"
loadBalancerCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/credential"
loadBalancer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/loadbalancer"
loadBalancerObservabilityCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/observability-credential"
@ -400,6 +409,12 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource
iaasNetwork.NewNetworkDataSource,
iaasNetworkArea.NewNetworkAreaDataSource,
iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource,
iaasNetworkInterface.NewNetworkInterfaceDataSource,
iaasVolume.NewVolumeDataSource,
iaasPublicIp.NewPublicIpDataSource,
iaasServer.NewServerDataSource,
iaasSecurityGroup.NewSecurityGroupDataSource,
iaasSecurityGroupRule.NewSecurityGroupRuleDataSource,
loadBalancer.NewLoadBalancerDataSource,
logMeInstance.NewInstanceDataSource,
logMeCredential.NewCredentialDataSource,
@ -444,6 +459,15 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
iaasNetwork.NewNetworkResource,
iaasNetworkArea.NewNetworkAreaResource,
iaasNetworkAreaRoute.NewNetworkAreaRouteResource,
iaasNetworkInterface.NewNetworkInterfaceResource,
iaasVolume.NewVolumeResource,
iaasPublicIp.NewPublicIpResource,
iaasVolumeAttach.NewVolumeAttachResource,
iaasNetworkInterfaceAttach.NewNetworkInterfaceAttachResource,
iaasServiceAccountAttach.NewServiceAccountAttachResource,
iaasServer.NewServerResource,
iaasSecurityGroup.NewSecurityGroupResource,
iaasSecurityGroupRule.NewSecurityGroupRuleResource,
loadBalancer.NewLoadBalancerResource,
loadBalancerCredential.NewCredentialResource,
loadBalancerObservabilityCredential.NewObservabilityCredentialResource,