diff --git a/docs/data-sources/key_pair.md b/docs/data-sources/key_pair.md new file mode 100644 index 00000000..f370793b --- /dev/null +++ b/docs/data-sources/key_pair.md @@ -0,0 +1,36 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_key_pair Data Source - stackit" +subcategory: "" +description: |- + Key pair 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_key_pair (Data Source) + +Key pair 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_key_pair" "example" { + name = "example-key-pair-name" +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the SSH key pair. + +### Read-Only + +- `fingerprint` (String) The fingerprint of the public SSH key. +- `id` (String) Terraform's internal resource ID. It takes the value of the key pair "`name`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. +- `public_key` (String) A string representation of the public SSH key. E.g., `ssh-rsa ` or `ssh-ed25519 `. diff --git a/docs/data-sources/public_ip.md b/docs/data-sources/public_ip.md index fb4010b7..bd4a480f 100644 --- a/docs/data-sources/public_ip.md +++ b/docs/data-sources/public_ip.md @@ -3,13 +3,13 @@ page_title: "stackit_public_ip Data Source - stackit" subcategory: "" description: |- - Volume resource schema. Must have a region specified in the provider configuration. + 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 (Data Source) -Volume resource schema. Must have a `region` specified in the provider configuration. +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. diff --git a/docs/resources/key_pair.md b/docs/resources/key_pair.md new file mode 100644 index 00000000..67bbf0ec --- /dev/null +++ b/docs/resources/key_pair.md @@ -0,0 +1,74 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_key_pair Resource - stackit" +subcategory: "" +description: |- + Key pair resource schema. Must have a region specified in the provider configuration. Allows uploading an SSH public key to be used for server authentication. + Usage with server + ```terraform + resource "stackitkeypair" "keypair" { + name = "example-key-pair" + publickey = chomp(file("path/to/idrsa.pub")) + } + resource "stackitserver" "example-server" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + bootvolume = { + size = 64 + sourcetype = "image" + sourceid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + availabilityzone = "eu01-1" + machinetype = "g1.1" + keypairname = "example-key-pair" + } + ~> 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_key_pair (Resource) + +Key pair resource schema. Must have a `region` specified in the provider configuration. Allows uploading an SSH public key to be used for server authentication. + + + +### Usage with server +```terraform +resource "stackit_key_pair" "keypair" { + name = "example-key-pair" + public_key = chomp(file("path/to/id_rsa.pub")) +} + +resource "stackit_server" "example-server" { + 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-key-pair" +} + + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + + + + +## Schema + +### Required + +- `name` (String) The name of the SSH key pair. +- `public_key` (String) A string representation of the public SSH key. E.g., `ssh-rsa ` or `ssh-ed25519 `. + +### Optional + +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. + +### Read-Only + +- `fingerprint` (String) The fingerprint of the public SSH key. +- `id` (String) Terraform's internal resource ID. It takes the value of the key pair "`name`". diff --git a/docs/resources/server.md b/docs/resources/server.md index 18ecd935..59b493c6 100644 --- a/docs/resources/server.md +++ b/docs/resources/server.md @@ -6,26 +6,44 @@ 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" + With key pair + ```terraform + resource "stackitkeypair" "keypair" { + name = "example-key-pair" + publickey = chomp(file("path/to/idrsa.pub")) } - - + resource "stackitserver" "user-data-from-file" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + bootvolume = { + size = 64 + sourcetype = "image" + sourceid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + name = "example-server" + machinetype = "g1.1" + keypairname = stackitkeypair.keypair.name + userdata = file("${path.module}/cloud-init.yaml") + } + ``` + Boot from volume + ```terraform + resource "stackitserver" "boot-from-volume" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-server" + bootvolume = { + size = 64 + sourcetype = "image" + sourceid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + availabilityzone = "eu01-1" + machinetype = "g1.1" + keypairname = "example-keypair" + } + ``` Boot from existing volume - - resource "stackit_volume" "example-volume" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + ```terraform + resource "stackitvolume" "example-volume" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" size = 12 source = { type = "image" @@ -34,129 +52,117 @@ description: |- name = "example-volume" availability_zone = "eu01-1" } - - resource "stackit_server" "boot-from-volume" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + resource "stackitserver" "boot-from-volume" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "example-server" - boot_volume = { - source_type = "volume" - source_id = stackit_volume.example-volume.volume_id + bootvolume = { + sourcetype = "volume" + sourceid = stackitvolume.example-volume.volumeid } - availability_zone = "eu01-1" - machine_type = "g1.1" - keypair_name = "example-keypair" + availabilityzone = "eu01-1" + machinetype = "g1.1" + keypairname = stackitkeypair.keypair.name } - - + ``` Network setup - - resource "stackit_server" "server-with-network" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + ```terraform + resource "stackitserver" "server-with-network" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "example-server" - boot_volume = { + bootvolume = { size = 64 - source_type = "image" - source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + sourcetype = "image" + sourceid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } - machine_type = "g1.1" - keypair_name = "example-keypair" + machinetype = "g1.1" + keypairname = stackitkey_pair.keypair.name } - - resource "stackit_network" "network" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + resource "stackitnetwork" "network" { + projectid = "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 + ipv4prefixlength = 24 } - - resource "stackit_security_group" "sec-group" { + resource "stackitsecuritygroup" "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 + resource "stackitsecuritygrouprule" "rule" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + securitygroupid = stackitsecuritygroup.sec-group.securitygroupid 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 "stackitnetworkinterface" "nic" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + networkid = stackitnetwork.network.networkid + securitygroupids = [stackitsecuritygroup.sec-group.securitygroupid] } - - resource "stackit_public_ip" "public-ip" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - network_interface_id = stackit_network_interface.nic.network_interface_id + resource "stackitpublicip" "public-ip" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + networkinterfaceid = stackitnetworkinterface.nic.networkinterface_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 + resource "stackitservernetworkinterfaceattach" "nic-attachment" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + serverid = stackitserver.server-with-network.serverid + networkinterfaceid = stackitnetworkinterface.nic.networkinterfaceid } - - + ``` Server with attached volume - - resource "stackit_volume" "example-volume" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + ```terraform + resource "stackitvolume" "example-volume" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" size = 12 - performance_class = "storage_premium_perf6" + performanceclass = "storagepremiumperf6" name = "example-volume" - availability_zone = "eu01-1" + availabilityzone = "eu01-1" } - - resource "stackit_server" "server-with-volume" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + resource "stackitserver" "server-with-volume" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "example-server" - boot_volume = { + bootvolume = { size = 64 - source_type = "image" - source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + sourcetype = "image" + sourceid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } - availability_zone = "eu01-1" - machine_type = "g1.1" - keypair_name = "example-keypair" + availabilityzone = "eu01-1" + machinetype = "g1.1" + keypairname = stackitkeypair.keypair.name } - - 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 + resource "stackitservervolumeattach" "attachvolume" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + serverid = stackitserver.server-with-volume.serverid + volumeid = stackitvolume.example-volume.volume_id } - - + ``` Server with user data (cloud-init) - - resource "stackit_server" "user-data" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - boot_volume = { + ```terraform + resource "stackitserver" "user-data" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + bootvolume = { size = 64 - source_type = "image" - source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + sourcetype = "image" + sourceid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } name = "example-server" - machine_type = "g1.1" - keypair_name = "example-keypair" - user_data = "#!/bin/bash\n/bin/su" + machinetype = "g1.1" + keypairname = stackitkeypair.keypair.name + userdata = "#!/bin/bash\n/bin/su" } - - resource "stackit_server" "user-data-from-file" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - boot_volume = { + resource "stackitserver" "user-data-from-file" { + projectid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + bootvolume = { size = 64 - source_type = "image" - source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + sourcetype = "image" + sourceid = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } name = "example-server" - machine_type = "g1.1" - keypair_name = "example-keypair" - user_data = file("${path.module}/cloud-init.yaml") + machinetype = "g1.1" + keypairname = stackitkeypair.keypair.name + userdata = file("${path.module}/cloud-init.yaml") } + ``` --- # stackit_server (Resource) @@ -167,6 +173,28 @@ Server resource schema. Must have a region specified in the provider configurati ## Example Usage +### With key pair +```terraform +resource "stackit_key_pair" "keypair" { + name = "example-key-pair" + public_key = chomp(file("path/to/id_rsa.pub")) +} + +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 = stackit_key_pair.keypair.name + user_data = file("${path.module}/cloud-init.yaml") +} + +``` + ### Boot from volume ```terraform resource "stackit_server" "boot-from-volume" { @@ -206,7 +234,7 @@ resource "stackit_server" "boot-from-volume" { } availability_zone = "eu01-1" machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name } ``` @@ -222,7 +250,7 @@ resource "stackit_server" "server-with-network" { source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name } resource "stackit_network" "network" { @@ -284,7 +312,7 @@ resource "stackit_server" "server-with-volume" { } availability_zone = "eu01-1" machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name } resource "stackit_server_volume_attach" "attach_volume" { @@ -306,7 +334,7 @@ resource "stackit_server" "user-data" { } name = "example-server" machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name user_data = "#!/bin/bash\n/bin/su" } @@ -319,7 +347,7 @@ resource "stackit_server" "user-data-from-file" { } name = "example-server" machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name user_data = file("${path.module}/cloud-init.yaml") } diff --git a/examples/data-sources/stackit_key_pair/data-source.tf b/examples/data-sources/stackit_key_pair/data-source.tf new file mode 100644 index 00000000..6fbd302b --- /dev/null +++ b/examples/data-sources/stackit_key_pair/data-source.tf @@ -0,0 +1,3 @@ +data "stackit_key_pair" "example" { + name = "example-key-pair-name" +} diff --git a/go.mod b/go.mod index ff14cb71..81a5cd2a 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,8 @@ 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.15.0 + github.com/stackitcloud/stackit-sdk-go/services/iaas v0.16.0 + github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.12-alpha github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.20.0 diff --git a/go.sum b/go.sum index 99055645..ed1084e4 100644 --- a/go.sum +++ b/go.sum @@ -155,8 +155,10 @@ 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.15.0 h1:bPNv+PuSykBcKCYVXHiYOcqNP+KLCA7XMFSY4V6J6ug= -github.com/stackitcloud/stackit-sdk-go/services/iaas v0.15.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM= +github.com/stackitcloud/stackit-sdk-go/services/iaas v0.16.0 h1:geyW780gqNxzSsPvmlxy3kUUJaRA4eiF9V3b2Ibcdjs= +github.com/stackitcloud/stackit-sdk-go/services/iaas v0.16.0/go.mod h1:YfuN+eXuqr846xeRyW2Vf1JM2jU0ikeQa76dDI66RsM= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.12-alpha h1:jwpif4t2gthmKmCXsQ84rmtDdcZkw4QQTFiCd7nTW8M= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.12-alpha/go.mod h1:nW/6vvumUHA7o1/JOOqsrEOBNrRHombEKB1U4jmg2wU= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0 h1:06CGP64CEk3Zg6i9kZCMRdmCzLLiyMWQqGK1teBr9Oc= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.17.0/go.mod h1:JL94zc8K0ebWs+DBGXR28vNCF0EFV54ZLUtrlXOvWgA= github.com/stackitcloud/stackit-sdk-go/services/logme v0.20.0 h1:V0UGP7JEa4Q8SsZFUJsKgLGaoPruLn2KVKnqQtaoWCU= diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index f95549a6..2cad184b 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -12,6 +12,7 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) @@ -96,6 +97,14 @@ var publicIpResource = map[string]string{ "network_interface_id": testutil.IaaSNetworkInterfaceId, } +// Key pair resource data +var keyPairResource = map[string]string{ + "name": "key-pair-name", + "public_key": `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIDsPd27M449akqCtdFg2+AmRVJz6eWio0oMP9dVg7XZ`, + "label1": "value1", + "label1-updated": "value1-updated", +} + func networkResourceConfig(name, nameservers string) string { return fmt.Sprintf(` resource "stackit_network" "network" { @@ -323,6 +332,13 @@ func testAccPublicIpConfig(publicIpResourceConfig string) string { ) } +func testAccKeyPairConfig(keyPairResourceConfig string) string { + return fmt.Sprintf("%s\n\n%s", + testutil.IaaSProviderConfig(), + keyPairResourceConfig, + ) +} + func TestAccNetworkArea(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -1113,6 +1129,117 @@ func TestAccPublicIp(t *testing.T) { }) } +func TestAccKeyPair(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIaaSKeyPairDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: testAccKeyPairConfig( + fmt.Sprintf(` + resource "stackit_key_pair" "key_pair" { + name = "%s" + public_key = "%s" + labels = { + "label1" = "%s" + } + } + `, + keyPairResource["name"], + keyPairResource["public_key"], + keyPairResource["label1"], + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_key_pair.key_pair", "name", keyPairResource["name"]), + resource.TestCheckResourceAttr("stackit_key_pair.key_pair", "labels.label1", keyPairResource["label1"]), + resource.TestCheckResourceAttr("stackit_key_pair.key_pair", "public_key", keyPairResource["public_key"]), + resource.TestCheckResourceAttrSet("stackit_key_pair.key_pair", "fingerprint"), + ), + }, + // Data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_key_pair" "key_pair" { + name = stackit_key_pair.key_pair.name + } + `, + testAccKeyPairConfig( + fmt.Sprintf(` + resource "stackit_key_pair" "key_pair" { + name = "%s" + public_key = "%s" + labels = { + "label1" = "%s" + } + } + `, + keyPairResource["name"], + keyPairResource["public_key"], + keyPairResource["label1"], + ), + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_key_pair.key_pair", "name", keyPairResource["name"]), + resource.TestCheckResourceAttr("data.stackit_key_pair.key_pair", "public_key", keyPairResource["public_key"]), + resource.TestCheckResourceAttr("stackit_key_pair.key_pair", "labels.label1", keyPairResource["label1"]), + resource.TestCheckResourceAttrPair( + "stackit_key_pair.key_pair", "fingerprint", + "data.stackit_key_pair.key_pair", "fingerprint", + ), + ), + }, + // Import + { + ResourceName: "stackit_key_pair.key_pair", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_key_pair.key_pair"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_key_pair.key_pair") + } + keyPairName, ok := r.Primary.Attributes["name"] + if !ok { + return "", fmt.Errorf("couldn't find attribute name") + } + return keyPairName, nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: testAccKeyPairConfig( + fmt.Sprintf(` + resource "stackit_key_pair" "key_pair" { + name = "%s" + public_key = "%s" + labels = { + "label1" = "%s" + } + } + `, + keyPairResource["name"], + keyPairResource["public_key"], + keyPairResource["label1-updated"], + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_key_pair.key_pair", "name", keyPairResource["name"]), + resource.TestCheckResourceAttr("stackit_key_pair.key_pair", "labels.label1", keyPairResource["label1-updated"]), + resource.TestCheckResourceAttrSet("stackit_key_pair.key_pair", "fingerprint"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + func testAccCheckNetworkAreaDestroy(s *terraform.State) error { ctx := context.Background() var client *iaas.APIClient @@ -1388,3 +1515,49 @@ func testAccCheckIaaSPublicIpDestroy(s *terraform.State) error { } return nil } + +func testAccCheckIaaSKeyPairDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaasalpha.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaasalpha.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaasalpha.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + keyPairsToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_key_pair" { + continue + } + // Key pair terraform ID: "[name]" + keyPairsToDestroy = append(keyPairsToDestroy, rs.Primary.ID) + } + + keyPairsResp, err := client.ListKeyPairsExecute(ctx) + if err != nil { + return fmt.Errorf("getting key pairs: %w", err) + } + + keyPairs := *keyPairsResp.Items + for i := range keyPairs { + if keyPairs[i].Name == nil { + continue + } + if utils.Contains(keyPairsToDestroy, *keyPairs[i].Name) { + err := client.DeleteKeyPairExecute(ctx, *keyPairs[i].Name) + if err != nil { + return fmt.Errorf("destroying key pair %s during CheckDestroy: %w", *keyPairs[i].Name, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/iaas/keypair/const.go b/stackit/internal/services/iaas/keypair/const.go new file mode 100644 index 00000000..fe8305bf --- /dev/null +++ b/stackit/internal/services/iaas/keypair/const.go @@ -0,0 +1,25 @@ +package keypair + +const exampleUsageWithServer = ` + +### Usage with server` + "\n" + + + "```terraform" + ` +resource "stackit_key_pair" "keypair" { + name = "example-key-pair" + public_key = chomp(file("path/to/id_rsa.pub")) +} + +resource "stackit_server" "example-server" { + 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-key-pair" +} +` diff --git a/stackit/internal/services/iaas/keypair/datasource.go b/stackit/internal/services/iaas/keypair/datasource.go new file mode 100644 index 00000000..02d75a46 --- /dev/null +++ b/stackit/internal/services/iaas/keypair/datasource.go @@ -0,0 +1,155 @@ +package keypair + +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/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" +) + +// keyPairDataSourceBetaCheckDone 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 keyPairDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &keyPairDataSource{} +) + +// NewVolumeDataSource is a helper function to simplify the provider implementation. +func NewKeyPairDataSource() datasource.DataSource { + return &keyPairDataSource{} +} + +// keyPairDataSource is the data source implementation. +type keyPairDataSource struct { + client *iaas.APIClient +} + +// Metadata returns the data source type name. +func (d *keyPairDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_key_pair" +} + +func (d *keyPairDataSource) 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 !keyPairDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_key_pair", "data source") + if resp.Diagnostics.HasError() { + return + } + keyPairDataSourceBetaCheckDone = 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 *keyPairDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + description := "Key pair resource schema. Must have a `region` specified in the provider configuration." + + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription(description), + Description: description, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It takes the value of the key pair \"`name`\".", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the SSH key pair.", + Required: true, + }, + "public_key": schema.StringAttribute{ + Description: "A string representation of the public SSH key. E.g., `ssh-rsa ` or `ssh-ed25519 `.", + Computed: true, + }, + "fingerprint": schema.StringAttribute{ + Description: "The fingerprint of the public SSH key.", + 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, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (r *keyPairDataSource) 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 + } + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + keypairResp, err := r.client.GetKeyPair(ctx, name).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 key pair", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, keypairResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading key pair", 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, "Key pair read") +} diff --git a/stackit/internal/services/iaas/keypair/resource.go b/stackit/internal/services/iaas/keypair/resource.go new file mode 100644 index 00000000..7b2d5924 --- /dev/null +++ b/stackit/internal/services/iaas/keypair/resource.go @@ -0,0 +1,411 @@ +package keypair + +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/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" +) + +// 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 = &keyPairResource{} + _ resource.ResourceWithConfigure = &keyPairResource{} + _ resource.ResourceWithImportState = &keyPairResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + Name types.String `tfsdk:"name"` + PublicKey types.String `tfsdk:"public_key"` + Fingerprint types.String `tfsdk:"fingerprint"` + Labels types.Map `tfsdk:"labels"` +} + +// NewKeyPairResource is a helper function to simplify the provider implementation. +func NewKeyPairResource() resource.Resource { + return &keyPairResource{} +} + +// keyPairResource is the resource implementation. +type keyPairResource struct { + client *iaas.APIClient +} + +// Metadata returns the resource type name. +func (r *keyPairResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_key_pair" +} + +// Configure adds the provider configured client to the resource. +func (r *keyPairResource) 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_key_pair", "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 *keyPairResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + description := "Key pair resource schema. Must have a `region` specified in the provider configuration. Allows uploading an SSH public key to be used for server authentication." + + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription(description + "\n\n" + exampleUsageWithServer), + Description: description, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It takes the value of the key pair \"`name`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the SSH key pair.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "public_key": schema.StringAttribute{ + Description: "A string representation of the public SSH key. E.g., `ssh-rsa ` or `ssh-ed25519 `.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "fingerprint": schema.StringAttribute{ + Description: "The fingerprint of the public SSH key.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container.", + ElementType: types.StringType, + Optional: true, + }, + }, + } +} + +// ModifyPlan will be called in the Plan phase. +// It will check if the plan contains a change that requires replacement. If yes, it will show a warning to the user. +func (r *keyPairResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + // If the state is empty we are creating a new resource + // If the plan is empty we are deleting the resource + // In both cases we don't need to check for replacement + if req.Plan.Raw.IsNull() || req.State.Raw.IsNull() { + return + } + + var planModel Model + diags := req.Plan.Get(ctx, &planModel) + resp.Diagnostics.Append(diags...) + + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + + if planModel.PublicKey.ValueString() != stateModel.PublicKey.ValueString() { + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Key pair public key change", "Changing the public key will trigger a replacement of the key pair resource. The new key pair will not be valid to access servers on which the old key was used, as the key is only registered during server creation.") + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *keyPairResource) 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 + } + + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating key pair", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new key pair + + keyPair, err := r.client.CreateKeyPair(ctx).CreateKeyPairPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating key pair", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, keyPair, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating key pair", 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, "Key pair created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *keyPairResource) 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 + } + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + keyPairResp, err := r.client.GetKeyPair(ctx, name).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 key pair", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, keyPairResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading key pair", 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, "Key pair read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *keyPairResource) 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 + } + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + // 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 key pair", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing key pair + updatedKeyPair, err := r.client.UpdateKeyPair(ctx, name).UpdateKeyPairPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating key pair", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, updatedKeyPair, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating key pair", 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, "key pair updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *keyPairResource) 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 + } + + name := model.Name.ValueString() + ctx = tflog.SetField(ctx, "name", name) + + // Delete existing key pair + err := r.client.DeleteKeyPair(ctx, name).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting key pair", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "Key pair deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,key_pair_id +func (r *keyPairResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 1 || idParts[0] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing key pair", + fmt.Sprintf("Expected import identifier with format: [name] Got: %q", req.ID), + ) + return + } + + name := idParts[0] + ctx = tflog.SetField(ctx, "name", name) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), name)...) + tflog.Info(ctx, "Key pair state imported") +} + +func mapFields(ctx context.Context, keyPairResp *iaas.Keypair, model *Model) error { + if keyPairResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var name string + if model.Name.ValueString() != "" { + name = model.Name.ValueString() + } else if keyPairResp.Name != nil { + name = *keyPairResp.Name + } else { + return fmt.Errorf("key pair name not present") + } + + model.Id = types.StringValue(name) + model.PublicKey = types.StringPointerValue(keyPairResp.PublicKey) + model.Fingerprint = types.StringPointerValue(keyPairResp.Fingerprint) + + 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 keyPairResp.Labels != nil && len(*keyPairResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *keyPairResp.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.Labels = labels + + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateKeyPairPayload, 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.CreateKeyPairPayload{ + Name: conversion.StringValueToPointer(model.Name), + PublicKey: conversion.StringValueToPointer(model.PublicKey), + Labels: &labels, + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateKeyPairPayload, 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.UpdateKeyPairPayload{ + Labels: &labels, + }, nil +} diff --git a/stackit/internal/services/iaas/keypair/resource_test.go b/stackit/internal/services/iaas/keypair/resource_test.go new file mode 100644 index 00000000..ed3af09a --- /dev/null +++ b/stackit/internal/services/iaas/keypair/resource_test.go @@ -0,0 +1,211 @@ +package keypair + +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.Keypair + expected Model + isValid bool + }{ + { + "default_values", + Model{ + Name: types.StringValue("name"), + }, + &iaas.Keypair{ + Name: utils.Ptr("name"), + }, + Model{ + Id: types.StringValue("name"), + Name: types.StringValue("name"), + PublicKey: types.StringNull(), + Fingerprint: types.StringNull(), + Labels: types.MapNull(types.StringType), + }, + true, + }, + { + "simple_values", + Model{ + Name: types.StringValue("name"), + }, + &iaas.Keypair{ + Name: utils.Ptr("name"), + PublicKey: utils.Ptr("public_key"), + Fingerprint: utils.Ptr("fingerprint"), + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + Model{ + Id: types.StringValue("name"), + Name: types.StringValue("name"), + PublicKey: types.StringValue("public_key"), + Fingerprint: types.StringValue("fingerprint"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + true, + }, + { + "empty_labels", + Model{ + Name: types.StringValue("name"), + }, + &iaas.Keypair{ + Name: utils.Ptr("name"), + PublicKey: utils.Ptr("public_key"), + Fingerprint: utils.Ptr("fingerprint"), + Labels: &map[string]interface{}{}, + }, + Model{ + Id: types.StringValue("name"), + Name: types.StringValue("name"), + PublicKey: types.StringValue("public_key"), + Fingerprint: types.StringValue("fingerprint"), + Labels: types.MapNull(types.StringType), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{}, + &iaas.Keypair{ + PublicKey: utils.Ptr("public_key"), + Fingerprint: utils.Ptr("fingerprint"), + Labels: &map[string]interface{}{}, + }, + 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.CreateKeyPairPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + PublicKey: types.StringValue("public_key"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key1": types.StringValue("value1"), + "key2": types.StringValue("value2"), + }), + }, + &iaas.CreateKeyPairPayload{ + Name: utils.Ptr("name"), + PublicKey: utils.Ptr("public_key"), + Labels: &map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + 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.UpdateKeyPairPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + PublicKey: types.StringValue("public_key"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key1": types.StringValue("value1"), + "key2": types.StringValue("value2"), + }), + }, + &iaas.UpdateKeyPairPayload{ + Labels: &map[string]interface{}{ + "key1": "value1", + "key2": "value2", + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected, cmp.AllowUnexported(iaas.NullableString{})) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/iaas/networkinterface/datasource.go b/stackit/internal/services/iaas/networkinterface/datasource.go index f754e3bd..0c5f941a 100644 --- a/stackit/internal/services/iaas/networkinterface/datasource.go +++ b/stackit/internal/services/iaas/networkinterface/datasource.go @@ -181,7 +181,7 @@ func (d *networkInterfaceDataSource) Read(ctx context.Context, req datasource.Re 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() + 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 { diff --git a/stackit/internal/services/iaas/networkinterface/resource.go b/stackit/internal/services/iaas/networkinterface/resource.go index c8f3ac57..087d67b6 100644 --- a/stackit/internal/services/iaas/networkinterface/resource.go +++ b/stackit/internal/services/iaas/networkinterface/resource.go @@ -271,7 +271,7 @@ func (r *networkInterfaceResource) Create(ctx context.Context, req resource.Crea } // Create new network interface - networkInterface, err := r.client.CreateNIC(ctx, projectId, networkId).CreateNICPayload(*payload).Execute() + 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 @@ -311,7 +311,7 @@ func (r *networkInterfaceResource) Read(ctx context.Context, req resource.ReadRe 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() + 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 { @@ -368,7 +368,7 @@ func (r *networkInterfaceResource) Update(ctx context.Context, req resource.Upda return } // Update existing network - nicResp, err := r.client.UpdateNIC(ctx, projectId, networkId, networkInterfaceId).UpdateNICPayload(*payload).Execute() + 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 @@ -405,7 +405,7 @@ func (r *networkInterfaceResource) Delete(ctx context.Context, req resource.Dele ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) // Delete existing network interface - err := r.client.DeleteNIC(ctx, projectId, networkId, networkInterfaceId).Execute() + 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 @@ -544,7 +544,7 @@ func mapFields(ctx context.Context, networkInterfaceResp *iaas.NIC, model *Model return nil } -func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNICPayload, error) { +func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNicPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -586,7 +586,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNICPayload, labelPayload = &labelMap } - return &iaas.CreateNICPayload{ + return &iaas.CreateNicPayload{ AllowedAddresses: allowedAddressesPayload, SecurityGroups: &modelSecurityGroups, Labels: labelPayload, @@ -599,7 +599,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*iaas.CreateNICPayload, }, nil } -func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateNICPayload, error) { +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaas.UpdateNicPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -637,7 +637,7 @@ func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) labelPayload = &labelMap } - return &iaas.UpdateNICPayload{ + return &iaas.UpdateNicPayload{ AllowedAddresses: &allowedAddressesPayload, SecurityGroups: &modelSecurityGroups, Labels: labelPayload, diff --git a/stackit/internal/services/iaas/networkinterface/resource_test.go b/stackit/internal/services/iaas/networkinterface/resource_test.go index 15dc7cc6..070c3c28 100644 --- a/stackit/internal/services/iaas/networkinterface/resource_test.go +++ b/stackit/internal/services/iaas/networkinterface/resource_test.go @@ -194,7 +194,7 @@ func TestToCreatePayload(t *testing.T) { tests := []struct { description string input *Model - expected *iaas.CreateNICPayload + expected *iaas.CreateNicPayload isValid bool }{ { @@ -210,7 +210,7 @@ func TestToCreatePayload(t *testing.T) { }), Security: types.BoolValue(true), }, - &iaas.CreateNICPayload{ + &iaas.CreateNicPayload{ Name: utils.Ptr("name"), SecurityGroups: &[]string{ "sg1", @@ -236,7 +236,7 @@ func TestToCreatePayload(t *testing.T) { AllowedAddresses: types.ListNull(types.StringType), }, - &iaas.CreateNICPayload{ + &iaas.CreateNicPayload{ Name: utils.Ptr("name"), SecurityGroups: &[]string{ "sg1", @@ -270,7 +270,7 @@ func TestToUpdatePayload(t *testing.T) { tests := []struct { description string input *Model - expected *iaas.UpdateNICPayload + expected *iaas.UpdateNicPayload isValid bool }{ { @@ -286,7 +286,7 @@ func TestToUpdatePayload(t *testing.T) { }), Security: types.BoolValue(true), }, - &iaas.UpdateNICPayload{ + &iaas.UpdateNicPayload{ Name: utils.Ptr("name"), SecurityGroups: &[]string{ "sg1", @@ -312,7 +312,7 @@ func TestToUpdatePayload(t *testing.T) { AllowedAddresses: types.ListNull(types.StringType), }, - &iaas.UpdateNICPayload{ + &iaas.UpdateNicPayload{ Name: utils.Ptr("name"), SecurityGroups: &[]string{ "sg1", diff --git a/stackit/internal/services/iaas/networkinterfaceattach/resource.go b/stackit/internal/services/iaas/networkinterfaceattach/resource.go index c2b353b5..7847abe8 100644 --- a/stackit/internal/services/iaas/networkinterfaceattach/resource.go +++ b/stackit/internal/services/iaas/networkinterfaceattach/resource.go @@ -169,7 +169,7 @@ func (r *networkInterfaceAttachResource) Create(ctx context.Context, req resourc ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) // Create new network interface attachment - err := r.client.AddNICToServer(ctx, projectId, serverId, networkInterfaceId).Execute() + 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 @@ -208,7 +208,7 @@ func (r *networkInterfaceAttachResource) Read(ctx context.Context, req resource. networkInterfaceId := model.NetworkInterfaceId.ValueString() ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) - nics, err := r.client.ListServerNICs(ctx, projectId, serverId).Execute() + 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 { @@ -267,7 +267,7 @@ func (r *networkInterfaceAttachResource) Delete(ctx context.Context, req resourc ctx = tflog.SetField(ctx, "network_interface_id", network_interfaceId) // Remove network_interface from server - err := r.client.RemoveNICFromServer(ctx, projectId, serverId, network_interfaceId).Execute() + 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 diff --git a/stackit/internal/services/iaas/publicip/datasource.go b/stackit/internal/services/iaas/publicip/datasource.go index f530b5a1..bd0d46e4 100644 --- a/stackit/internal/services/iaas/publicip/datasource.go +++ b/stackit/internal/services/iaas/publicip/datasource.go @@ -89,8 +89,8 @@ func (d *publicIpDataSource) Configure(ctx context.Context, req datasource.Confi // 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.", + 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 datasource ID. It is structured as \"`project_id`,`public_ip_id`\".", diff --git a/stackit/internal/services/iaas/server/const.go b/stackit/internal/services/iaas/server/const.go index 6db84be9..0ed54368 100644 --- a/stackit/internal/services/iaas/server/const.go +++ b/stackit/internal/services/iaas/server/const.go @@ -5,6 +5,28 @@ Server resource schema. Must have a region specified in the provider configurati ~> 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" + ` +### With key pair` + "\n" + + + "```terraform" + ` +resource "stackit_key_pair" "keypair" { + name = "example-key-pair" + public_key = chomp(file("path/to/id_rsa.pub")) +} + +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 = stackit_key_pair.keypair.name + user_data = file("${path.module}/cloud-init.yaml") +} +` + "\n```" + ` + ### Boot from volume` + "\n" + "```terraform" + ` @@ -45,7 +67,7 @@ resource "stackit_server" "boot-from-volume" { } availability_zone = "eu01-1" machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name } ` + "\n```" + ` @@ -61,7 +83,7 @@ resource "stackit_server" "server-with-network" { source_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name } resource "stackit_network" "network" { @@ -123,7 +145,7 @@ resource "stackit_server" "server-with-volume" { } availability_zone = "eu01-1" machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name } resource "stackit_server_volume_attach" "attach_volume" { @@ -145,7 +167,7 @@ resource "stackit_server" "user-data" { } name = "example-server" machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name user_data = "#!/bin/bash\n/bin/su" } @@ -158,7 +180,7 @@ resource "stackit_server" "user-data-from-file" { } name = "example-server" machine_type = "g1.1" - keypair_name = "example-keypair" + keypair_name = stackit_key_pair.keypair.name user_data = file("${path.module}/cloud-init.yaml") } ` + "\n```" diff --git a/stackit/internal/services/iaas/server/resource.go b/stackit/internal/services/iaas/server/resource.go index 99a8a897..e4c071a7 100644 --- a/stackit/internal/services/iaas/server/resource.go +++ b/stackit/internal/services/iaas/server/resource.go @@ -563,7 +563,7 @@ func mapFields(ctx context.Context, serverResp *iaas.Server, model *Model) error } else if serverResp.Id != nil { serverId = *serverResp.Id } else { - return fmt.Errorf("Server id not present") + return fmt.Errorf("server id not present") } idParts := []string{ diff --git a/stackit/provider.go b/stackit/provider.go index 620e604e..f3fd0286 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -14,6 +14,7 @@ import ( argusScrapeConfig "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/argus/scrapeconfig" dnsRecordSet "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/recordset" dnsZone "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/zone" + iaasKeyPair "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/keypair" 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" @@ -412,6 +413,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasNetworkInterface.NewNetworkInterfaceDataSource, iaasVolume.NewVolumeDataSource, iaasPublicIp.NewPublicIpDataSource, + iaasKeyPair.NewKeyPairDataSource, iaasServer.NewServerDataSource, iaasSecurityGroup.NewSecurityGroupDataSource, iaasSecurityGroupRule.NewSecurityGroupRuleDataSource, @@ -462,6 +464,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasNetworkInterface.NewNetworkInterfaceResource, iaasVolume.NewVolumeResource, iaasPublicIp.NewPublicIpResource, + iaasKeyPair.NewKeyPairResource, iaasVolumeAttach.NewVolumeAttachResource, iaasNetworkInterfaceAttach.NewNetworkInterfaceAttachResource, iaasServiceAccountAttach.NewServiceAccountAttachResource,