From 65a3572b826da33bb0303989eae96dfd4259a743 Mon Sep 17 00:00:00 2001 From: Vicente Pinto Date: Wed, 2 Oct 2024 10:05:09 +0100 Subject: [PATCH 1/6] 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 --- docs/data-sources/volume.md | 35 ++ docs/resources/loadbalancer.md | 28 +- docs/resources/volume.md | 50 ++ .../data-sources/stackit_volume/resource.tf | 4 + examples/resources/stackit_volume/resource.tf | 9 + go.mod | 1 + go.sum | 2 + .../internal/services/iaas/iaas_acc_test.go | 179 ++++++ .../services/iaas/volume/datasource.go | 187 +++++++ .../internal/services/iaas/volume/resource.go | 529 ++++++++++++++++++ .../services/iaas/volume/resource_test.go | 234 ++++++++ stackit/internal/testutil/testutil.go | 1 + stackit/internal/utils/utils.go | 36 ++ stackit/internal/utils/utils_test.go | 137 +++++ stackit/provider.go | 3 + 15 files changed, 1422 insertions(+), 13 deletions(-) create mode 100644 docs/data-sources/volume.md create mode 100644 docs/resources/volume.md create mode 100644 examples/data-sources/stackit_volume/resource.tf create mode 100644 examples/resources/stackit_volume/resource.tf create mode 100644 stackit/internal/services/iaas/volume/datasource.go create mode 100644 stackit/internal/services/iaas/volume/resource.go create mode 100644 stackit/internal/services/iaas/volume/resource_test.go diff --git a/docs/data-sources/volume.md b/docs/data-sources/volume.md new file mode 100644 index 00000000..e6ef74e2 --- /dev/null +++ b/docs/data-sources/volume.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_volume Data Source - stackit" +subcategory: "" +description: |- + Volume resource schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_volume (Data Source) + +Volume resource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + + + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the volume is associated. +- `volume_id` (String) The volume ID. + +### Read-Only + +- `availability_zone` (String) The availability zone of the volume. +- `description` (String) The description of the volume. +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`volume_id`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `name` (String) The name of the volume. +- `performance_class` (String) The performance class of the volume. +- `server_id` (String) The server ID of the server to which the volume is attached to. +- `size` (Number) The size of the volume in GB. It can only be updated to a larger value than the current size diff --git a/docs/resources/loadbalancer.md b/docs/resources/loadbalancer.md index e5e4bbfe..ad84605c 100644 --- a/docs/resources/loadbalancer.md +++ b/docs/resources/loadbalancer.md @@ -8,23 +8,25 @@ description: |- To automate the creation of load balancers, OpenStack can be used to setup the supporting infrastructure. To set up the OpenStack provider, you can create a token through the STACKIT Portal, in your project's Infrastructure API page. There, the OpenStack user domain name, username, and password are generated and can be obtained. The provider can then be configured as follows: - ```terraform + terraform { - required_providers { - (...) - openstack = { - source = "terraform-provider-openstack/openstack" - } - } + required_providers { + (...) + openstack = { + source = "terraform-provider-openstack/openstack" + } + } } + provider "openstack" { - user_domain_name = "{OpenStack user domain name}" - user_name = "{OpenStack username}" - password = "{OpenStack password}" - region = "RegionOne" - auth_url = "https://keystone.api.iaas.eu01.stackit.cloud/v3" + user_domain_name = "{OpenStack user domain name}" + user_name = "{OpenStack username}" + password = "{OpenStack password}" + region = "RegionOne" + auth_url = "https://keystone.api.iaas.eu01.stackit.cloud/v3" } - ``` + + Configuring the supporting infrastructure The example below uses OpenStack to create the network, router, a public IP address and a compute instance. --- diff --git a/docs/resources/volume.md b/docs/resources/volume.md new file mode 100644 index 00000000..f0631949 --- /dev/null +++ b/docs/resources/volume.md @@ -0,0 +1,50 @@ +--- +# 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-m" + size = 64 + labels = { + "key" = "value" + } +} +``` + + +## Schema + +### Required + +- `availability_zone` (String) The availability zone of the volume. +- `project_id` (String) STACKIT project ID to which the volume is associated. + +### Optional + +- `description` (String) The description of the volume. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `name` (String) The name of the volume. +- `performance_class` (String) The performance class of the volume. +- `server_id` (String) The server ID of the server to which the volume is attached to. +- `size` (Number) The size of the volume in GB. It can only be updated to a larger value than the current size + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`volume_id`". +- `volume_id` (String) The volume ID. diff --git a/examples/data-sources/stackit_volume/resource.tf b/examples/data-sources/stackit_volume/resource.tf new file mode 100644 index 00000000..ad48eb29 --- /dev/null +++ b/examples/data-sources/stackit_volume/resource.tf @@ -0,0 +1,4 @@ +resource "stackit_volume" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + volume_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_volume/resource.tf b/examples/resources/stackit_volume/resource.tf new file mode 100644 index 00000000..ed896f5d --- /dev/null +++ b/examples/resources/stackit_volume/resource.tf @@ -0,0 +1,9 @@ +resource "stackit_volume" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_volume" + availability_zone = "eu01-m" + size = 64 + labels = { + "key" = "value" + } +} diff --git a/go.mod b/go.mod index f7a3fb23..4bdd0592 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.8.0 + github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.5-alpha github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.19.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.19.0 diff --git a/go.sum b/go.sum index cf5bdbbc..fc33c1d8 100644 --- a/go.sum +++ b/go.sum @@ -157,6 +157,8 @@ github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 h1:QIZfs6nJ/l2pOweH1 github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0/go.mod h1:MdZcRbs19s2NLeJmSLSoqTzm9IPIQhE1ZEMpo9gePq0= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.8.0 h1:RPqCcepRpm4c2POVuq+W41UwhXEfSRQAM696evHIOlQ= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.8.0/go.mod h1:XtJA9FMK/yJ0dj4HtRAogmZPRUsZiFcuwUSfHYNASjo= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.5-alpha h1:myjZNUEsd03lnKoy2rCAdAziGpMqDOJc9xB6OIrSrLA= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.5-alpha/go.mod h1:b4KR6r+yWS2hsDkz6ebRqxgadB+ZsAZcG0oDfv5jeaY= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0 h1:/GwkGMD7ID5hSjdZs1l/Mj8waceCt7oj3TxHgBfEMDQ= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0/go.mod h1:wsO3+vXe1XiKLeCIctWAptaHQZ07Un7kmLTQ+drbj7w= github.com/stackitcloud/stackit-sdk-go/services/logme v0.19.0 h1:KcsF549yXOrm8zlqFCNV+lf2L4zvQuh4L2i3kgdWbOE= diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index 407df54f..05c26ae5 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" ) @@ -39,6 +40,17 @@ var networkAreaRouteResource = map[string]string{ "next_hop": "1.1.1.1", } +// Volume resource data +var volumeResource = map[string]string{ + "project_id": testutil.ProjectId, + "availability_zone": "eu01-1", + "name": "name", + "description": "description", + "size": "1", + "label1": "value", + "performance_class": "storage_premium_perf1", +} + func networkResourceConfig(name, nameservers string) string { return fmt.Sprintf(` resource "stackit_network" "network" { @@ -87,6 +99,30 @@ func networkAreaRouteResourceConfig() string { ) } +func volumeResourceConfig(name, size string) string { + return fmt.Sprintf(` + resource "stackit_volume" "volume" { + project_id = "%s" + availability_zone = "%s" + name = "%s" + description = "%s" + size = %s + labels = { + "label1" = "%s" + } + performance_class = "%s" + } + `, + volumeResource["project_id"], + volumeResource["availability_zone"], + name, + volumeResource["description"], + size, + volumeResource["label1"], + volumeResource["performance_class"], + ) +} + func resourceConfig(name, nameservers, areaname, networkranges string) string { return fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s", testutil.IaaSProviderConfig(), @@ -96,6 +132,13 @@ func resourceConfig(name, nameservers, areaname, networkranges string) string { ) } +func resourceConfigVolume(name, size string) string { + return fmt.Sprintf("%s\n\n%s", + testutil.IaaSProviderConfig(), + volumeResourceConfig(name, size), + ) +} + func TestAccIaaS(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -296,6 +339,95 @@ func TestAccIaaS(t *testing.T) { }) } +func TestAccIaaSVolume(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIaaSVolumeDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: resourceConfigVolume(volumeResource["name"], volumeResource["size"]), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_volume.volume", "project_id", volumeResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_volume.volume", "volume_id"), + resource.TestCheckResourceAttr("stackit_volume.volume", "name", volumeResource["name"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "labels.label1", volumeResource["label1"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "description", volumeResource["description"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "performance_class", volumeResource["performance_class"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "size", volumeResource["size"]), + ), + }, + // Data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_volume" "volume" { + project_id = stackit_volume.volume.project_id + volume_id = stackit_volume.volume.volume_id + } + `, + resourceConfigVolume(volumeResource["name"], volumeResource["size"]), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_volume.volume", "project_id", networkResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_volume.volume", "volume_id", + "data.stackit_volume.volume", "volume_id", + ), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "name", volumeResource["name"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "labels.label1", volumeResource["label1"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "description", volumeResource["description"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "performance_class", volumeResource["performance_class"]), + resource.TestCheckResourceAttr("data.stackit_volume.volume", "size", volumeResource["size"]), + ), + }, + // Import + { + ResourceName: "stackit_volume.volume", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_volume.volume"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_volume.volume") + } + volumeId, ok := r.Primary.Attributes["volume_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute volume_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, volumeId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: resourceConfigVolume( + fmt.Sprintf("%s-updated", volumeResource["name"]), + "10", + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_volume.volume", "project_id", volumeResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_volume.volume", "volume_id"), + resource.TestCheckResourceAttr("stackit_volume.volume", "name", fmt.Sprintf("%s-updated", volumeResource["name"])), + resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "labels.label1", volumeResource["label1"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "description", volumeResource["description"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "performance_class", volumeResource["performance_class"]), + resource.TestCheckResourceAttr("stackit_volume.volume", "size", "10"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + func testAccCheckIaaSDestroy(s *terraform.State) error { ctx := context.Background() var client *iaas.APIClient @@ -370,3 +502,50 @@ func testAccCheckIaaSDestroy(s *terraform.State) error { } return nil } + +func testAccCheckIaaSVolumeDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaasalpha.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaasalpha.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaasalpha.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + volumesToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_volume" { + continue + } + // volume terraform ID: "[project_id],[volume_id]" + volumeId := strings.Split(rs.Primary.ID, core.Separator)[1] + volumesToDestroy = append(volumesToDestroy, volumeId) + } + + volumesResp, err := client.ListVolumesExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting volumesResp: %w", err) + } + + volumes := *volumesResp.Items + for i := range volumes { + if volumes[i].Id == nil { + continue + } + if utils.Contains(volumesToDestroy, *volumes[i].Id) { + err := client.DeleteVolumeExecute(ctx, testutil.ProjectId, *volumes[i].Id) + if err != nil { + return fmt.Errorf("destroying volume %s during CheckDestroy: %w", *volumes[i].Id, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/iaas/volume/datasource.go b/stackit/internal/services/iaas/volume/datasource.go new file mode 100644 index 00000000..e91941bb --- /dev/null +++ b/stackit/internal/services/iaas/volume/datasource.go @@ -0,0 +1,187 @@ +package volume + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// volumeDataSourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var volumeDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &volumeDataSource{} +) + +// NewVolumeDataSource is a helper function to simplify the provider implementation. +func NewVolumeDataSource() datasource.DataSource { + return &volumeDataSource{} +} + +// volumeDataSource is the data source implementation. +type volumeDataSource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the data source type name. +func (d *volumeDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_volume" +} + +func (d *volumeDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + var apiClient *iaasalpha.APIClient + var err error + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !volumeDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_volume", "data source") + if resp.Diagnostics.HasError() { + return + } + volumeDataSourceBetaCheckDone = true + } + + if providerData.IaaSCustomEndpoint != "" { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err)) + return + } + + d.client = apiClient + tflog.Info(ctx, "iaasalpha client configured") +} + +// Schema defines the schema for the resource. +func (r *volumeDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Volume resource schema. Must have a `region` specified in the provider configuration."), + Description: "Volume resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`volume_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the volume is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "volume_id": schema.StringAttribute{ + Description: "The volume ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID of the server to which the volume is attached to.", + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the volume.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "The description of the volume.", + Computed: true, + }, + "availability_zone": schema.StringAttribute{ + Description: "The availability zone of the volume.", + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, + "performance_class": schema.StringAttribute{ + Description: "The performance class of the volume.", + Computed: true, + }, + "size": schema.Int64Attribute{ + Description: "The size of the volume in GB. It can only be updated to a larger value than the current size", + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *volumeDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + volumeResp, err := d.client.GetVolume(ctx, projectId, volumeId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, volumeResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "volume read") +} diff --git a/stackit/internal/services/iaas/volume/resource.go b/stackit/internal/services/iaas/volume/resource.go new file mode 100644 index 00000000..a38c0e57 --- /dev/null +++ b/stackit/internal/services/iaas/volume/resource.go @@ -0,0 +1,529 @@ +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/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-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &volumeResource{} + _ resource.ResourceWithConfigure = &volumeResource{} + _ resource.ResourceWithImportState = &volumeResource{} +) + +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"` +} + +// NewVolumeResource is a helper function to simplify the provider implementation. +func NewVolumeResource() resource.Resource { + return &volumeResource{} +} + +// volumeResource is the resource implementation. +type volumeResource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the resource type name. +func (r *volumeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_volume" +} + +// ConfigValidators validates the resource configuration +func (r *volumeResource) ConfigValidators(_ context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + resourcevalidator.AtLeastOneOf( + path.MatchRoot("size"), + ), + } +} + +// Configure adds the provider configured client to the resource. +func (r *volumeResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_volume", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "iaasalpha client configured") +} + +// Schema defines the schema for the resource. +func (r *volumeResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Volume resource schema. Must have a `region` specified in the provider configuration."), + Description: "Volume resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`volume_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the volume is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "volume_id": schema.StringAttribute{ + Description: "The volume ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID of the server to which the volume is attached to.", + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the volume.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "description": schema.StringAttribute{ + Description: "The description of the volume.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(127), + }, + }, + "availability_zone": schema.StringAttribute{ + Description: "The availability zone of the volume.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Required: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, + "performance_class": schema.StringAttribute{ + Description: "The performance class of the volume.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "size": schema.Int64Attribute{ + Description: "The size of the volume in GB. It can only be updated to a larger value than the current size", + Optional: true, + }, + }, + } +} + +// 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) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new volume + + volume, err := r.client.CreateVolume(ctx, projectId).CreateVolumePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Calling API: %v", err)) + return + } + + volumeId := *volume.Id + volume, err = wait.CreateVolumeWaitHandler(ctx, r.client, projectId, volumeId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("volume creation waiting: %v", err)) + return + } + + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + // Map response body to schema + err = mapFields(ctx, volume, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Volume created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *volumeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + volumeResp, err := r.client.GetVolume(ctx, projectId, volumeId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, volumeResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "volume read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *volumeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing volume + updatedVolume, err := r.client.UpdateVolume(ctx, projectId, volumeId).UpdateVolumePayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Resize existing volume + modelSize := conversion.Int64ValueToPointer(model.Size) + if modelSize != nil && updatedVolume.Size != nil { + // A volume can only be resized to larger values, otherwise an error occurs + if *modelSize < *updatedVolume.Size { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("The new volume size must be larger than the current size (%d GB)", *updatedVolume.Size)) + } else if *modelSize > *updatedVolume.Size { + payload := iaasalpha.ResizeVolumePayload{ + Size: modelSize, + } + err := r.client.ResizeVolume(ctx, projectId, volumeId).ResizeVolumePayload(payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Resizing the volume, calling API: %v", err)) + } + // Update volume model because the API doesn't return a volume object as response + updatedVolume.Size = modelSize + } + } + err = mapFields(ctx, updatedVolume, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "volume updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *volumeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + volumeId := model.VolumeId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + // Delete existing volume + err := r.client.DeleteVolume(ctx, projectId, volumeId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting volume", fmt.Sprintf("Calling API: %v", err)) + return + } + _, err = wait.DeleteVolumeWaitHandler(ctx, r.client, projectId, volumeId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting volume", fmt.Sprintf("volume deletion waiting: %v", err)) + return + } + + tflog.Info(ctx, "volume deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,volume_id +func (r *volumeResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing volume", + fmt.Sprintf("Expected import identifier with format: [project_id],[volume_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + volumeId := idParts[1] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "volume_id", volumeId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("volume_id"), volumeId)...) + tflog.Info(ctx, "volume state imported") +} + +func mapFields(ctx context.Context, volumeResp *iaasalpha.Volume, model *Model) error { + if volumeResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var volumeId string + if model.VolumeId.ValueString() != "" { + volumeId = model.VolumeId.ValueString() + } else if volumeResp.Id != nil { + volumeId = *volumeResp.Id + } else { + return fmt.Errorf("Volume id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + volumeId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + var labels basetypes.MapValue + 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 { + labels = types.MapNull(types.StringType) + } + + 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) + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaasalpha.CreateVolumePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaasalpha.CreateVolumePayload{ + AvailabilityZone: conversion.StringValueToPointer(model.AvailabilityZone), + Description: conversion.StringValueToPointer(model.Description), + Labels: &labels, + Name: conversion.StringValueToPointer(model.Name), + PerformanceClass: conversion.StringValueToPointer(model.PerformanceClass), + Size: conversion.Int64ValueToPointer(model.Size), + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaasalpha.UpdateVolumePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := utils.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaasalpha.UpdateVolumePayload{ + Description: conversion.StringValueToPointer(model.Description), + Name: conversion.StringValueToPointer(model.Name), + Labels: &labels, + }, nil +} diff --git a/stackit/internal/services/iaas/volume/resource_test.go b/stackit/internal/services/iaas/volume/resource_test.go new file mode 100644 index 00000000..53d1aed0 --- /dev/null +++ b/stackit/internal/services/iaas/volume/resource_test.go @@ -0,0 +1,234 @@ +package volume + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaasalpha.Volume + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + }, + &iaasalpha.Volume{ + Id: utils.Ptr("nid"), + }, + Model{ + Id: types.StringValue("pid,nid"), + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + Name: types.StringNull(), + AvailabilityZone: types.StringNull(), + Labels: types.MapNull(types.StringType), + Description: types.StringNull(), + PerformanceClass: types.StringNull(), + ServerId: types.StringNull(), + Size: types.Int64Null(), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + }, + // &sourceModel{}, + &iaasalpha.Volume{ + Id: utils.Ptr("nid"), + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + PerformanceClass: utils.Ptr("class"), + ServerId: utils.Ptr("sid"), + Size: utils.Ptr(int64(1)), + }, + 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), + }, + true, + }, + { + "empty_labels", + Model{ + ProjectId: types.StringValue("pid"), + VolumeId: types.StringValue("nid"), + }, + &iaasalpha.Volume{ + Id: utils.Ptr("nid"), + Labels: &map[string]interface{}{}, + }, + 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(), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaasalpha.Volume{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.CreateVolumePayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + PerformanceClass: types.StringValue("class"), + Size: types.Int64Value(1), + }, + &iaasalpha.CreateVolumePayload{ + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + PerformanceClass: utils.Ptr("class"), + Size: utils.Ptr(int64(1)), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.UpdateVolumePayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + }, + &iaasalpha.UpdateVolumePayload{ + Name: utils.Ptr("name"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index d4b36acb..9b009e0d 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -120,6 +120,7 @@ func IaaSProviderConfig() string { return ` provider "stackit" { region = "eu01" + enable_beta_resources = true }` } return fmt.Sprintf(` diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index f4764659..15d66f40 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -1,12 +1,14 @@ package utils import ( + "context" "fmt" "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -104,3 +106,37 @@ func QuoteValues(values []string) []string { func IsLegacyProjectRole(role string) bool { return utils.Contains(LegacyProjectRoles, role) } + +// 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 := conversion.ToStringInterfaceMap(ctx, current) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + desiredMap, err := conversion.ToStringInterfaceMap(ctx, desired) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + mapPayload := map[string]interface{}{} + // Update and remove existing keys + for k := range currentMap { + if desiredValue, ok := desiredMap[k]; ok { + mapPayload[k] = desiredValue + } else { + mapPayload[k] = nil + } + } + + // Add new keys + for k, desiredValue := range desiredMap { + if _, ok := mapPayload[k]; !ok { + mapPayload[k] = desiredValue + } + } + return mapPayload, nil +} diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index 3ab01e58..ff123782 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -1,6 +1,7 @@ package utils import ( + "context" "testing" "github.com/google/go-cmp/cmp" @@ -268,3 +269,139 @@ func TestIsLegacyProjectRole(t *testing.T) { }) } } + +func TestToJSONMapUpdatePayload(t *testing.T) { + tests := []struct { + description string + currentLabels types.Map + desiredLabels types.Map + expected map[string]interface{} + isValid bool + }{ + { + "nothing_to_update", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + map[string]interface{}{ + "key": "value", + }, + true, + }, + { + "update_key_value", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("updated_value"), + }), + map[string]interface{}{ + "key": "updated_value", + }, + true, + }, + { + "remove_key", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + map[string]interface{}{ + "key": "value", + "key2": nil, + }, + true, + }, + { + "add_new_key", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + true, + }, + { + "empty_desired_map", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{}), + map[string]interface{}{ + "key": nil, + "key2": nil, + }, + true, + }, + { + "nil_desired_map", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + types.MapNull(types.StringType), + map[string]interface{}{ + "key": nil, + "key2": nil, + }, + true, + }, + { + "empty_current_map", + types.MapValueMust(types.StringType, map[string]attr.Value{}), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + true, + }, + { + "nil_current_map", + types.MapNull(types.StringType), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := ToJSONMapPartialUpdatePayload(context.Background(), tt.currentLabels, tt.desiredLabels) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/provider.go b/stackit/provider.go index 796417fe..571fc09c 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -17,6 +17,7 @@ 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" + iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume" loadBalancerCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/credential" loadBalancer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/loadbalancer" loadBalancerObservabilityCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/observability-credential" @@ -400,6 +401,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasNetwork.NewNetworkDataSource, iaasNetworkArea.NewNetworkAreaDataSource, iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource, + iaasVolume.NewVolumeDataSource, loadBalancer.NewLoadBalancerDataSource, logMeInstance.NewInstanceDataSource, logMeCredential.NewCredentialDataSource, @@ -444,6 +446,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasNetwork.NewNetworkResource, iaasNetworkArea.NewNetworkAreaResource, iaasNetworkAreaRoute.NewNetworkAreaRouteResource, + iaasVolume.NewVolumeResource, loadBalancer.NewLoadBalancerResource, loadBalancerCredential.NewCredentialResource, loadBalancerObservabilityCredential.NewObservabilityCredentialResource, From 8894383c2c4e4f0036d061f594c0c40200de0aff Mon Sep 17 00:00:00 2001 From: Vicente Pinto Date: Thu, 3 Oct 2024 10:51:28 +0100 Subject: [PATCH 2/6] Volume source field (#542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Onboard IaaS Volume * Labels mapping * Add acceptance test * Remove source field * Fix lint * Add examples and docs * Fix lint * Fix lint * Fix lint * Add source field supoort * Fix labels and source mapping * Remove unecessary source mapping * Move methods to conversion pkg * Revert change * Update stackit/internal/services/iaas/volume/datasource.go Co-authored-by: João Palet * Update stackit/internal/services/iaas/volume/resource.go Co-authored-by: João Palet * Update stackit/internal/services/iaas/volume/resource.go Co-authored-by: João Palet * Update stackit/internal/services/iaas/volume/resource.go Co-authored-by: João Palet * Changes after review * Change after revie --------- Co-authored-by: João Palet --- docs/data-sources/volume.md | 9 ++ docs/resources/volume.md | 25 +++- stackit/internal/conversion/conversion.go | 34 +++++ .../internal/conversion/conversion_test.go | 137 ++++++++++++++++++ .../services/iaas/volume/datasource.go | 15 ++ .../internal/services/iaas/volume/resource.go | 91 +++++++++++- .../services/iaas/volume/resource_test.go | 29 +++- stackit/internal/utils/utils.go | 36 ----- stackit/internal/utils/utils_test.go | 137 ------------------ 9 files changed, 321 insertions(+), 192 deletions(-) diff --git a/docs/data-sources/volume.md b/docs/data-sources/volume.md index e6ef74e2..ba903e6d 100644 --- a/docs/data-sources/volume.md +++ b/docs/data-sources/volume.md @@ -33,3 +33,12 @@ Volume resource schema. Must have a `region` specified in the provider configura - `performance_class` (String) The performance class of the volume. - `server_id` (String) The server ID of the server to which the volume is attached to. - `size` (Number) The size of the volume in GB. It can only be updated to a larger value than the current size +- `source` (Attributes) The source of the volume. It can be either a volume, an image, a snapshot or a backup (see [below for nested schema](#nestedatt--source)) + + +### Nested Schema for `source` + +Read-Only: + +- `id` (String) The ID of the source, e.g. image ID +- `type` (String) The type of the source. Supported values are: `volume`, `image`, `snapshot`, `backup`. diff --git a/docs/resources/volume.md b/docs/resources/volume.md index f0631949..e94560f3 100644 --- a/docs/resources/volume.md +++ b/docs/resources/volume.md @@ -17,13 +17,13 @@ Volume resource schema. Must have a `region` specified in the provider configura ```terraform resource "stackit_volume" "example" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - name = "my_volume" - availability_zone = "eu01-m" - size = 64 - labels = { - "key" = "value" - } + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_volume" + availability_zone = "eu01-m" + size = 64 + labels = { + "key" = "value" + } } ``` @@ -42,9 +42,18 @@ resource "stackit_volume" "example" { - `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 +- `size` (Number) The size of the volume in GB. It can only be updated to a larger value than the current size. Either `size` or `source` must be provided +- `source` (Attributes) The source of the volume. It can be either a volume, an image, a snapshot or a backup. Either `size` or `source` must be provided (see [below for nested schema](#nestedatt--source)) ### Read-Only - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`volume_id`". - `volume_id` (String) The volume ID. + + +### Nested Schema for `source` + +Required: + +- `id` (String) The ID of the source, e.g. image ID +- `type` (String) The type of the source. Supported values are: `volume`, `image`, `snapshot`, `backup`. diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go index ccc017c3..ee009b13 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -133,3 +133,37 @@ func StringListToPointer(list basetypes.ListValue) (*[]string, error) { return &listStr, nil } + +// ToJSONMApPartialUpdatePayload returns a map[string]interface{} to be used in a PATCH request payload. +// It takes a current map as it is in the terraform state and a desired map as it is in the user configuratiom +// and builds a map which sets to null keys that should be removed, updates the values of existing keys and adds new keys +// This method is needed because in partial updates, e.g. if the key is not provided it is ignored and not removed +func ToJSONMapPartialUpdatePayload(ctx context.Context, current, desired types.Map) (map[string]interface{}, error) { + currentMap, err := ToStringInterfaceMap(ctx, current) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + desiredMap, err := ToStringInterfaceMap(ctx, desired) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + mapPayload := map[string]interface{}{} + // Update and remove existing keys + for k := range currentMap { + if desiredValue, ok := desiredMap[k]; ok { + mapPayload[k] = desiredValue + } else { + mapPayload[k] = nil + } + } + + // Add new keys + for k, desiredValue := range desiredMap { + if _, ok := mapPayload[k]; !ok { + mapPayload[k] = desiredValue + } + } + return mapPayload, nil +} diff --git a/stackit/internal/conversion/conversion_test.go b/stackit/internal/conversion/conversion_test.go index 5662e7d3..b37b154e 100644 --- a/stackit/internal/conversion/conversion_test.go +++ b/stackit/internal/conversion/conversion_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -80,3 +81,139 @@ func TestFromTerraformStringMapToInterfaceMap(t *testing.T) { }) } } + +func TestToJSONMapUpdatePayload(t *testing.T) { + tests := []struct { + description string + currentLabels types.Map + desiredLabels types.Map + expected map[string]interface{} + isValid bool + }{ + { + "nothing_to_update", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + map[string]interface{}{ + "key": "value", + }, + true, + }, + { + "update_key_value", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("updated_value"), + }), + map[string]interface{}{ + "key": "updated_value", + }, + true, + }, + { + "remove_key", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + map[string]interface{}{ + "key": "value", + "key2": nil, + }, + true, + }, + { + "add_new_key", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + true, + }, + { + "empty_desired_map", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + types.MapValueMust(types.StringType, map[string]attr.Value{}), + map[string]interface{}{ + "key": nil, + "key2": nil, + }, + true, + }, + { + "nil_desired_map", + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + types.MapNull(types.StringType), + map[string]interface{}{ + "key": nil, + "key2": nil, + }, + true, + }, + { + "empty_current_map", + types.MapValueMust(types.StringType, map[string]attr.Value{}), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + true, + }, + { + "nil_current_map", + types.MapNull(types.StringType), + types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + "key2": types.StringValue("value2"), + }), + map[string]interface{}{ + "key": "value", + "key2": "value2", + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := ToJSONMapPartialUpdatePayload(context.Background(), tt.currentLabels, tt.desiredLabels) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/iaas/volume/datasource.go b/stackit/internal/services/iaas/volume/datasource.go index e91941bb..e6c422af 100644 --- a/stackit/internal/services/iaas/volume/datasource.go +++ b/stackit/internal/services/iaas/volume/datasource.go @@ -15,6 +15,7 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" ) @@ -145,6 +146,20 @@ func (r *volumeDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, 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, + }, + }, + }, }, } } diff --git a/stackit/internal/services/iaas/volume/resource.go b/stackit/internal/services/iaas/volume/resource.go index a38c0e57..7f8d443c 100644 --- a/stackit/internal/services/iaas/volume/resource.go +++ b/stackit/internal/services/iaas/volume/resource.go @@ -9,10 +9,12 @@ import ( "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" @@ -40,6 +42,8 @@ var ( _ resource.Resource = &volumeResource{} _ resource.ResourceWithConfigure = &volumeResource{} _ resource.ResourceWithImportState = &volumeResource{} + + SupportedSourceTypes = []string{"volume", "image", "snapshot", "backup"} ) type Model struct { @@ -53,6 +57,19 @@ type Model struct { 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. @@ -74,6 +91,7 @@ func (r *volumeResource) Metadata(_ context.Context, req resource.MetadataReques func (r *volumeResource) ConfigValidators(_ context.Context) []resource.ConfigValidator { return []resource.ConfigValidator{ resourcevalidator.AtLeastOneOf( + path.MatchRoot("source"), path.MatchRoot("size"), ), } @@ -227,9 +245,32 @@ func (r *volumeResource) Schema(_ context.Context, _ resource.SchemaRequest, res }, }, "size": schema.Int64Attribute{ - Description: "The size of the volume in GB. It can only be updated to a larger value than the current size", + 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(), + }, + }, + }, + }, }, } } @@ -247,8 +288,17 @@ func (r *volumeResource) Create(ctx context.Context, req resource.CreateRequest, 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) + 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 @@ -469,17 +519,35 @@ func mapFields(ctx context.Context, volumeResp *iaasalpha.Volume, model *Model) strings.Join(idParts, core.Separator), ) - var labels basetypes.MapValue + 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 { + } 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) @@ -488,10 +556,11 @@ func mapFields(ctx context.Context, volumeResp *iaasalpha.Volume, model *Model) 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) (*iaasalpha.CreateVolumePayload, error) { +func toCreatePayload(ctx context.Context, model *Model, source *sourceModel) (*iaasalpha.CreateVolumePayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -501,6 +570,15 @@ func toCreatePayload(ctx context.Context, model *Model) (*iaasalpha.CreateVolume return nil, fmt.Errorf("converting to Go map: %w", err) } + var sourcePayload *iaasalpha.VolumeSource + + if !source.Id.IsNull() && !source.Type.IsNull() { + sourcePayload = &iaasalpha.VolumeSource{ + Id: conversion.StringValueToPointer(source.Id), + Type: conversion.StringValueToPointer(source.Type), + } + } + return &iaasalpha.CreateVolumePayload{ AvailabilityZone: conversion.StringValueToPointer(model.AvailabilityZone), Description: conversion.StringValueToPointer(model.Description), @@ -508,6 +586,7 @@ func toCreatePayload(ctx context.Context, model *Model) (*iaasalpha.CreateVolume Name: conversion.StringValueToPointer(model.Name), PerformanceClass: conversion.StringValueToPointer(model.PerformanceClass), Size: conversion.Int64ValueToPointer(model.Size), + Source: sourcePayload, }, nil } @@ -516,7 +595,7 @@ func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) return nil, fmt.Errorf("nil model") } - labels, err := utils.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) if err != nil { return nil, fmt.Errorf("converting to Go map: %w", err) } diff --git a/stackit/internal/services/iaas/volume/resource_test.go b/stackit/internal/services/iaas/volume/resource_test.go index 53d1aed0..d0b7338d 100644 --- a/stackit/internal/services/iaas/volume/resource_test.go +++ b/stackit/internal/services/iaas/volume/resource_test.go @@ -39,6 +39,7 @@ func TestMapFields(t *testing.T) { PerformanceClass: types.StringNull(), ServerId: types.StringNull(), Size: types.Int64Null(), + Source: types.ObjectNull(sourceTypes), }, true, }, @@ -48,7 +49,6 @@ func TestMapFields(t *testing.T) { ProjectId: types.StringValue("pid"), VolumeId: types.StringValue("nid"), }, - // &sourceModel{}, &iaasalpha.Volume{ Id: utils.Ptr("nid"), Name: utils.Ptr("name"), @@ -60,6 +60,7 @@ func TestMapFields(t *testing.T) { PerformanceClass: utils.Ptr("class"), ServerId: utils.Ptr("sid"), Size: utils.Ptr(int64(1)), + Source: &iaasalpha.VolumeSource{}, }, Model{ Id: types.StringValue("pid,nid"), @@ -74,6 +75,10 @@ func TestMapFields(t *testing.T) { 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, }, @@ -82,10 +87,10 @@ func TestMapFields(t *testing.T) { Model{ ProjectId: types.StringValue("pid"), VolumeId: types.StringValue("nid"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), }, &iaasalpha.Volume{ - Id: utils.Ptr("nid"), - Labels: &map[string]interface{}{}, + Id: utils.Ptr("nid"), }, Model{ Id: types.StringValue("pid,nid"), @@ -93,11 +98,12 @@ func TestMapFields(t *testing.T) { VolumeId: types.StringValue("nid"), Name: types.StringNull(), AvailabilityZone: types.StringNull(), - Labels: types.MapNull(types.StringType), + 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, }, @@ -141,6 +147,7 @@ func TestToCreatePayload(t *testing.T) { tests := []struct { description string input *Model + source *sourceModel expected *iaasalpha.CreateVolumePayload isValid bool }{ @@ -155,6 +162,14 @@ func TestToCreatePayload(t *testing.T) { Description: types.StringValue("desc"), PerformanceClass: types.StringValue("class"), Size: types.Int64Value(1), + Source: types.ObjectValueMust(sourceTypes, map[string]attr.Value{ + "type": types.StringNull(), + "id": types.StringNull(), + }), + }, + &sourceModel{ + Type: types.StringValue("volume"), + Id: types.StringValue("id"), }, &iaasalpha.CreateVolumePayload{ Name: utils.Ptr("name"), @@ -165,13 +180,17 @@ func TestToCreatePayload(t *testing.T) { Description: utils.Ptr("desc"), PerformanceClass: utils.Ptr("class"), Size: utils.Ptr(int64(1)), + Source: &iaasalpha.VolumeSource{ + Type: utils.Ptr("volume"), + Id: utils.Ptr("id"), + }, }, true, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - output, err := toCreatePayload(context.Background(), tt.input) + output, err := toCreatePayload(context.Background(), tt.input, tt.source) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index 15d66f40..f4764659 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -1,14 +1,12 @@ package utils import ( - "context" "fmt" "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/core/utils" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -106,37 +104,3 @@ func QuoteValues(values []string) []string { func IsLegacyProjectRole(role string) bool { return utils.Contains(LegacyProjectRoles, role) } - -// 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 := conversion.ToStringInterfaceMap(ctx, current) - if err != nil { - return nil, fmt.Errorf("converting to Go map: %w", err) - } - - desiredMap, err := conversion.ToStringInterfaceMap(ctx, desired) - if err != nil { - return nil, fmt.Errorf("converting to Go map: %w", err) - } - - mapPayload := map[string]interface{}{} - // Update and remove existing keys - for k := range currentMap { - if desiredValue, ok := desiredMap[k]; ok { - mapPayload[k] = desiredValue - } else { - mapPayload[k] = nil - } - } - - // Add new keys - for k, desiredValue := range desiredMap { - if _, ok := mapPayload[k]; !ok { - mapPayload[k] = desiredValue - } - } - return mapPayload, nil -} diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index ff123782..3ab01e58 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -1,7 +1,6 @@ package utils import ( - "context" "testing" "github.com/google/go-cmp/cmp" @@ -269,139 +268,3 @@ func TestIsLegacyProjectRole(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) - } - } - }) - } -} From ad987b0a5587b7f87de3006abab97421527a5558 Mon Sep 17 00:00:00 2001 From: GokceGK <161626272+GokceGK@users.noreply.github.com> Date: Mon, 7 Oct 2024 16:21:57 +0200 Subject: [PATCH 3/6] Onboard IaaS security groups (#545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * onboard iaas security group * add examples and generate docs * fix linter issues * fix deletion * Update stackit/internal/services/iaas/securitygroup/resource.go Co-authored-by: Vicente Pinto * rename data source example file * update docs * remove field * remove field * remove plan modifier from the name field * refactor labels in mapFields * change function from utils to conversion * remove rules from the security group * update docs * add security group acceptance test * add plan modifiers to stateful field * sort imports * change stateful description --------- Co-authored-by: Gökçe Gök Klingel Co-authored-by: Vicente Pinto --- docs/data-sources/security_group.md | 39 ++ docs/resources/security_group.md | 45 ++ .../stackit_security_group/data-source.tf | 4 + .../stackit_security_group/resource.tf | 7 + .../internal/services/iaas/iaas_acc_test.go | 156 ++++++ .../services/iaas/securitygroup/datasource.go | 171 +++++++ .../services/iaas/securitygroup/resource.go | 451 ++++++++++++++++++ .../iaas/securitygroup/resource_test.go | 218 +++++++++ stackit/provider.go | 3 + 9 files changed, 1094 insertions(+) create mode 100644 docs/data-sources/security_group.md create mode 100644 docs/resources/security_group.md create mode 100644 examples/data-sources/stackit_security_group/data-source.tf create mode 100644 examples/resources/stackit_security_group/resource.tf create mode 100644 stackit/internal/services/iaas/securitygroup/datasource.go create mode 100644 stackit/internal/services/iaas/securitygroup/resource.go create mode 100644 stackit/internal/services/iaas/securitygroup/resource_test.go diff --git a/docs/data-sources/security_group.md b/docs/data-sources/security_group.md new file mode 100644 index 00000000..4eaf8dd6 --- /dev/null +++ b/docs/data-sources/security_group.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_security_group Data Source - stackit" +subcategory: "" +description: |- + Security group datasource schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_security_group (Data Source) + +Security group datasource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +resource "stackit_security_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the security group is associated. +- `security_group_id` (String) The security group ID. + +### Read-Only + +- `description` (String) The description of the security group. +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`security_group_id`". +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `name` (String) The name of the security group. +- `stateful` (Boolean) Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server. diff --git a/docs/resources/security_group.md b/docs/resources/security_group.md new file mode 100644 index 00000000..bc5ee7ca --- /dev/null +++ b/docs/resources/security_group.md @@ -0,0 +1,45 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_security_group Resource - stackit" +subcategory: "" +description: |- + Security group resource schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_security_group (Resource) + +Security group resource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +resource "stackit_security_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_security_group" + labels = { + "key" = "value" + } +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the security group. +- `project_id` (String) STACKIT project ID to which the security group is associated. + +### Optional + +- `description` (String) The description of the security group. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `stateful` (Boolean) Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server. + +### Read-Only + +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`security_group_id`". +- `security_group_id` (String) The security group ID. diff --git a/examples/data-sources/stackit_security_group/data-source.tf b/examples/data-sources/stackit_security_group/data-source.tf new file mode 100644 index 00000000..b40f16d7 --- /dev/null +++ b/examples/data-sources/stackit_security_group/data-source.tf @@ -0,0 +1,4 @@ +resource "stackit_security_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_security_group/resource.tf b/examples/resources/stackit_security_group/resource.tf new file mode 100644 index 00000000..a67ea42d --- /dev/null +++ b/examples/resources/stackit_security_group/resource.tf @@ -0,0 +1,7 @@ +resource "stackit_security_group" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_security_group" + labels = { + "key" = "value" + } +} diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index 05c26ae5..c768e151 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -51,6 +51,14 @@ var volumeResource = map[string]string{ "performance_class": "storage_premium_perf1", } +// Security Group resource data +var securityGroupResource = map[string]string{ + "project_id": testutil.ProjectId, + "name": "name", + "description": "description", + "label1": "value", +} + func networkResourceConfig(name, nameservers string) string { return fmt.Sprintf(` resource "stackit_network" "network" { @@ -123,6 +131,24 @@ func volumeResourceConfig(name, size string) string { ) } +func securityGroupResourceConfig(name string) string { + return fmt.Sprintf(` + resource "stackit_security_group" "security_group" { + project_id = "%s" + name = "%s" + description = "%s" + labels = { + "label1" = "%s" + } + } + `, + volumeResource["project_id"], + name, + volumeResource["description"], + volumeResource["label1"], + ) +} + func resourceConfig(name, nameservers, areaname, networkranges string) string { return fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s", testutil.IaaSProviderConfig(), @@ -139,6 +165,13 @@ func resourceConfigVolume(name, size string) string { ) } +func resourceConfigSecurityGroup(name string) string { + return fmt.Sprintf("%s\n\n%s", + testutil.IaaSProviderConfig(), + securityGroupResourceConfig(name), + ) +} + func TestAccIaaS(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -428,6 +461,82 @@ func TestAccIaaSVolume(t *testing.T) { }) } +func TestAccIaaSSecurityGroup(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckIaaSSecurityGroupDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: resourceConfigSecurityGroup(securityGroupResource["name"]), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_security_group.security_group", "project_id", securityGroupResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_security_group.security_group", "security_group_id"), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "name", securityGroupResource["name"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "labels.label1", securityGroupResource["label1"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "description", securityGroupResource["description"]), + ), + }, + // Data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_security_group" "security_group" { + project_id = stackit_security_group.security_group.project_id + security_group_id = stackit_security_group.security_group.security_group_id + } + `, + resourceConfigSecurityGroup(securityGroupResource["name"]), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_security_group.security_group", "project_id", securityGroupResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_security_group.security_group", "security_group_id", + "data.stackit_security_group.security_group", "security_group_id", + ), + resource.TestCheckResourceAttr("data.stackit_security_group.security_group", "name", securityGroupResource["name"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "labels.label1", securityGroupResource["label1"]), + resource.TestCheckResourceAttr("data.stackit_security_group.security_group", "description", securityGroupResource["description"]), + ), + }, + // Import + { + ResourceName: "stackit_security_group.security_group", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_security_group.security_group"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_security_group.security_group") + } + securityGroupId, ok := r.Primary.Attributes["security_group_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute security_group_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, securityGroupId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: resourceConfigSecurityGroup( + fmt.Sprintf("%s-updated", securityGroupResource["name"]), + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_security_group.security_group", "project_id", securityGroupResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_security_group.security_group", "security_group_id"), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "name", fmt.Sprintf("%s-updated", securityGroupResource["name"])), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "labels.label1", securityGroupResource["label1"]), + resource.TestCheckResourceAttr("stackit_security_group.security_group", "description", securityGroupResource["description"]), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + func testAccCheckIaaSDestroy(s *terraform.State) error { ctx := context.Background() var client *iaas.APIClient @@ -549,3 +658,50 @@ func testAccCheckIaaSVolumeDestroy(s *terraform.State) error { } return nil } + +func testAccCheckIaaSSecurityGroupDestroy(s *terraform.State) error { + ctx := context.Background() + var client *iaasalpha.APIClient + var err error + if testutil.IaaSCustomEndpoint == "" { + client, err = iaasalpha.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + client, err = iaasalpha.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + securityGroupsToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_security_group" { + continue + } + // security group terraform ID: "[project_id],[security_group_id]" + securityGroupId := strings.Split(rs.Primary.ID, core.Separator)[1] + securityGroupsToDestroy = append(securityGroupsToDestroy, securityGroupId) + } + + securityGroupsResp, err := client.ListSecurityGroupsExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting securityGroupsResp: %w", err) + } + + securityGroups := *securityGroupsResp.Items + for i := range securityGroups { + if securityGroups[i].Id == nil { + continue + } + if utils.Contains(securityGroupsToDestroy, *securityGroups[i].Id) { + err := client.DeleteSecurityGroupExecute(ctx, testutil.ProjectId, *securityGroups[i].Id) + if err != nil { + return fmt.Errorf("destroying security group %s during CheckDestroy: %w", *securityGroups[i].Id, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/iaas/securitygroup/datasource.go b/stackit/internal/services/iaas/securitygroup/datasource.go new file mode 100644 index 00000000..0de00660 --- /dev/null +++ b/stackit/internal/services/iaas/securitygroup/datasource.go @@ -0,0 +1,171 @@ +package securitygroup + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// securityGroupDataSourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var securityGroupDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &securityGroupDataSource{} +) + +// NewSecurityGroupDataSource is a helper function to simplify the provider implementation. +func NewSecurityGroupDataSource() datasource.DataSource { + return &securityGroupDataSource{} +} + +// securityGroupDataSource is the data source implementation. +type securityGroupDataSource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the data source type name. +func (d *securityGroupDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_security_group" +} + +func (d *securityGroupDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + var apiClient *iaasalpha.APIClient + var err error + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !securityGroupDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_security_group", "data source") + if resp.Diagnostics.HasError() { + return + } + securityGroupDataSourceBetaCheckDone = true + } + + if providerData.IaaSCustomEndpoint != "" { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err)) + return + } + + d.client = apiClient + tflog.Info(ctx, "iaasalpha client configured") +} + +// Schema defines the schema for the resource. +func (r *securityGroupDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Security group datasource schema. Must have a `region` specified in the provider configuration."), + Description: "Security group datasource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`security_group_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the security group is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "security_group_id": schema.StringAttribute{ + Description: "The security group ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the security group.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "The description of the security group.", + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, + "stateful": schema.BoolAttribute{ + Description: "Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server.", + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *securityGroupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + securityGroupResp, err := d.client.GetSecurityGroup(ctx, projectId, securityGroupId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, securityGroupResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "security group read") +} diff --git a/stackit/internal/services/iaas/securitygroup/resource.go b/stackit/internal/services/iaas/securitygroup/resource.go new file mode 100644 index 00000000..05a63e02 --- /dev/null +++ b/stackit/internal/services/iaas/securitygroup/resource.go @@ -0,0 +1,451 @@ +package securitygroup + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &securityGroupResource{} + _ resource.ResourceWithConfigure = &securityGroupResource{} + _ resource.ResourceWithImportState = &securityGroupResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + SecurityGroupId types.String `tfsdk:"security_group_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Labels types.Map `tfsdk:"labels"` + Stateful types.Bool `tfsdk:"stateful"` +} + +// NewSecurityGroupResource is a helper function to simplify the provider implementation. +func NewSecurityGroupResource() resource.Resource { + return &securityGroupResource{} +} + +// securityGroupResource is the resource implementation. +type securityGroupResource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the resource type name. +func (r *securityGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_security_group" +} + +// Configure adds the provider configured client to the resource. +func (r *securityGroupResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_security_group", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "iaasalpha client configured") +} + +// Schema defines the schema for the resource. +func (r *securityGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Security group resource schema. Must have a `region` specified in the provider configuration."), + Description: "Security group resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`security_group_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the security group is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "security_group_id": schema.StringAttribute{ + Description: "The security group ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the security group.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "description": schema.StringAttribute{ + Description: "The description of the security group.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(127), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, + "stateful": schema.BoolAttribute{ + Description: "Configures if a security group is stateful or stateless. There can only be one type of security groups per network interface/server.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + boolplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *securityGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new security group + + securityGroup, err := r.client.CreateSecurityGroup(ctx, projectId).CreateSecurityGroupPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + securityGroupId := *securityGroup.Id + + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + // Map response body to schema + err = mapFields(ctx, securityGroup, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating security group", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Security group created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *securityGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_id", securityGroupId) + + securityGroupResp, err := r.client.GetSecurityGroup(ctx, projectId, securityGroupId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, securityGroupResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading security group", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "security group read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *securityGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing security group + updatedSecurityGroup, err := r.client.V1alpha1UpdateSecurityGroup(ctx, projectId, securityGroupId).V1alpha1UpdateSecurityGroupPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, updatedSecurityGroup, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating security group", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "security group updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *securityGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + securityGroupId := model.SecurityGroupId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + // Delete existing security group + err := r.client.DeleteSecurityGroup(ctx, projectId, securityGroupId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting security group", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "security group deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,security_group_id +func (r *securityGroupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing security group", + fmt.Sprintf("Expected import identifier with format: [project_id],[security_group_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + securityGroupId := idParts[1] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "security_group_id", securityGroupId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("security_group_id"), securityGroupId)...) + tflog.Info(ctx, "security group state imported") +} + +func mapFields(ctx context.Context, securityGroupResp *iaasalpha.SecurityGroup, model *Model) error { + if securityGroupResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var securityGroupId string + if model.SecurityGroupId.ValueString() != "" { + securityGroupId = model.SecurityGroupId.ValueString() + } else if securityGroupResp.Id != nil { + securityGroupId = *securityGroupResp.Id + } else { + return fmt.Errorf("security group id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + securityGroupId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if securityGroupResp.Labels != nil && len(*securityGroupResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *securityGroupResp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + + model.SecurityGroupId = types.StringValue(securityGroupId) + model.Name = types.StringPointerValue(securityGroupResp.Name) + model.Description = types.StringPointerValue(securityGroupResp.Description) + model.Stateful = types.BoolPointerValue(securityGroupResp.Stateful) + model.Labels = labels + + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaasalpha.CreateSecurityGroupPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaasalpha.CreateSecurityGroupPayload{ + Stateful: conversion.BoolValueToPointer(model.Stateful), + Description: conversion.StringValueToPointer(model.Description), + Labels: &labels, + Name: conversion.StringValueToPointer(model.Name), + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaasalpha.V1alpha1UpdateSecurityGroupPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaasalpha.V1alpha1UpdateSecurityGroupPayload{ + Description: conversion.StringValueToPointer(model.Description), + Name: conversion.StringValueToPointer(model.Name), + Labels: &labels, + }, nil +} diff --git a/stackit/internal/services/iaas/securitygroup/resource_test.go b/stackit/internal/services/iaas/securitygroup/resource_test.go new file mode 100644 index 00000000..4e4ea5c3 --- /dev/null +++ b/stackit/internal/services/iaas/securitygroup/resource_test.go @@ -0,0 +1,218 @@ +package securitygroup + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaasalpha.SecurityGroup + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + }, + &iaasalpha.SecurityGroup{ + Id: utils.Ptr("sgid"), + }, + Model{ + Id: types.StringValue("pid,sgid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + Name: types.StringNull(), + Labels: types.MapNull(types.StringType), + Description: types.StringNull(), + Stateful: types.BoolNull(), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + }, + // &sourceModel{}, + &iaasalpha.SecurityGroup{ + Id: utils.Ptr("sgid"), + Name: utils.Ptr("name"), + Stateful: utils.Ptr(true), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + }, + Model{ + Id: types.StringValue("pid,sgid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + Name: types.StringValue("name"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + Stateful: types.BoolValue(true), + }, + true, + }, + { + "empty_labels", + Model{ + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + }, + &iaasalpha.SecurityGroup{ + Id: utils.Ptr("sgid"), + Labels: &map[string]interface{}{}, + }, + Model{ + Id: types.StringValue("pid,sgid"), + ProjectId: types.StringValue("pid"), + SecurityGroupId: types.StringValue("sgid"), + Name: types.StringNull(), + Labels: types.MapNull(types.StringType), + Description: types.StringNull(), + Stateful: types.BoolNull(), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaasalpha.SecurityGroup{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.CreateSecurityGroupPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + Stateful: types.BoolValue(true), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + }, + &iaasalpha.CreateSecurityGroupPayload{ + Name: utils.Ptr("name"), + Stateful: utils.Ptr(true), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.V1alpha1UpdateSecurityGroupPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + Description: types.StringValue("desc"), + }, + &iaasalpha.V1alpha1UpdateSecurityGroupPayload{ + Name: utils.Ptr("name"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Description: utils.Ptr("desc"), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/provider.go b/stackit/provider.go index 571fc09c..147454dd 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -17,6 +17,7 @@ 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" + iaasSecurityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygroup" iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume" loadBalancerCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/credential" loadBalancer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/loadbalancer" @@ -402,6 +403,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasNetworkArea.NewNetworkAreaDataSource, iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource, iaasVolume.NewVolumeDataSource, + iaasSecurityGroup.NewSecurityGroupDataSource, loadBalancer.NewLoadBalancerDataSource, logMeInstance.NewInstanceDataSource, logMeCredential.NewCredentialDataSource, @@ -447,6 +449,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasNetworkArea.NewNetworkAreaResource, iaasNetworkAreaRoute.NewNetworkAreaRouteResource, iaasVolume.NewVolumeResource, + iaasSecurityGroup.NewSecurityGroupResource, loadBalancer.NewLoadBalancerResource, loadBalancerCredential.NewCredentialResource, loadBalancerObservabilityCredential.NewObservabilityCredentialResource, From 8f723660843a7fa79cb25519e5f90245effe3fcd Mon Sep 17 00:00:00 2001 From: Vicente Pinto Date: Mon, 7 Oct 2024 16:14:58 +0100 Subject: [PATCH 4/6] 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 --- docs/data-sources/server.md | 61 ++ docs/resources/server.md | 89 ++ docs/resources/volume.md | 2 +- examples/resources/stackit_server/resource.tf | 19 + examples/resources/stackit_volume/resource.tf | 2 +- go.mod | 2 +- go.sum | 4 +- .../internal/services/iaas/iaas_acc_test.go | 423 +++++++--- .../services/iaas/server/datasource.go | 244 ++++++ .../internal/services/iaas/server/resource.go | 795 ++++++++++++++++++ .../services/iaas/server/resource_test.go | 322 +++++++ stackit/internal/testutil/testutil.go | 2 + stackit/provider.go | 3 + 13 files changed, 1856 insertions(+), 112 deletions(-) create mode 100644 docs/data-sources/server.md create mode 100644 docs/resources/server.md create mode 100644 examples/resources/stackit_server/resource.tf create mode 100644 stackit/internal/services/iaas/server/datasource.go create mode 100644 stackit/internal/services/iaas/server/resource.go create mode 100644 stackit/internal/services/iaas/server/resource_test.go diff --git a/docs/data-sources/server.md b/docs/data-sources/server.md new file mode 100644 index 00000000..e687a922 --- /dev/null +++ b/docs/data-sources/server.md @@ -0,0 +1,61 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_server Data Source - stackit" +subcategory: "" +description: |- + Server datasource schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_server (Data Source) + +Server datasource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + + + + +## Schema + +### Required + +- `project_id` (String) STACKIT project ID to which the server is associated. +- `server_id` (String) The server ID. + +### Read-Only + +- `availability_zone` (String) The availability zone of the server. +- `boot_volume` (Attributes) The boot volume for the server (see [below for nested schema](#nestedatt--boot_volume)) +- `created_at` (String) Date-time when the server was created +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`". +- `image_id` (String) The image ID to be used for an ephemeral disk on the server. +- `initial_networking` (Attributes) The initial networking setup for the server. A network ID or a list of network interfaces IDs can be provided (see [below for nested schema](#nestedatt--initial_networking)) +- `keypair_name` (String) The name of the keypair used during server creation. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `launched_at` (String) Date-time when the server was launched +- `machine_type` (String) Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html) +- `name` (String) The name of the server. +- `server_group` (String) The server group the server is assigned to. +- `updated_at` (String) Date-time when the server was updated +- `user_data` (String) User data that is passed via cloud-init to the server. + + +### Nested Schema for `boot_volume` + +Read-Only: + +- `id` (String) The ID of the source, either image ID or volume ID +- `performance_class` (String) The performance class of the server. +- `size` (Number) The size of the boot volume in GB. +- `type` (String) The type of the source. Supported values are: `volume`, `image`. + + + +### Nested Schema for `initial_networking` + +Read-Only: + +- `network_id` (String) The network ID +- `network_interface_ids` (List of String) List of network interface IDs +- `security_group_ids` (List of String) List of security groups the server is associated to diff --git a/docs/resources/server.md b/docs/resources/server.md new file mode 100644 index 00000000..5f3a4972 --- /dev/null +++ b/docs/resources/server.md @@ -0,0 +1,89 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_server Resource - stackit" +subcategory: "" +description: |- + Server resource schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_server (Resource) + +Server resource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +resource "stackit_server" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "IMAGE_ID" + } + initial_networking = { + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_ids = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] + } + availability_zone = "eu01-1" + labels = { + "key" = "value" + } + machine_type = "t1.1" + keypair_name = "my_key_pair_name" +} +``` + + +## Schema + +### Required + +- `machine_type` (String) Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html) +- `name` (String) The name of the server. +- `project_id` (String) STACKIT project ID to which the server is associated. + +### Optional + +- `availability_zone` (String) The availability zone of the server. +- `boot_volume` (Attributes) The boot volume for the server (see [below for nested schema](#nestedatt--boot_volume)) +- `image_id` (String) The image ID to be used for an ephemeral disk on the server. +- `initial_networking` (Attributes) The initial networking setup for the server. A network ID or a list of network interfaces IDs can be provided (see [below for nested schema](#nestedatt--initial_networking)) +- `keypair_name` (String) The name of the keypair used during server creation. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container +- `server_group` (String) The server group the server is assigned to. +- `user_data` (String) User data that is passed via cloud-init to the server. + +### Read-Only + +- `created_at` (String) Date-time when the server was created +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`server_id`". +- `launched_at` (String) Date-time when the server was launched +- `server_id` (String) The server ID. +- `updated_at` (String) Date-time when the server was updated + + +### Nested Schema for `boot_volume` + +Required: + +- `source_id` (String) The ID of the source, either image ID or volume ID +- `source_type` (String) The type of the source. Supported values are: `volume`, `image`. + +Optional: + +- `performance_class` (String) The performance class of the server. +- `size` (Number) The size of the boot volume in GB. Must be provided when `source_type` is `image`. + + + +### Nested Schema for `initial_networking` + +Optional: + +- `network_id` (String) The network ID +- `network_interface_ids` (List of String) List of network interface IDs +- `security_group_ids` (List of String) List of security groups the initial network is assigned to diff --git a/docs/resources/volume.md b/docs/resources/volume.md index e94560f3..3230ffb8 100644 --- a/docs/resources/volume.md +++ b/docs/resources/volume.md @@ -19,7 +19,7 @@ Volume resource schema. Must have a `region` specified in the provider configura resource "stackit_volume" "example" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "my_volume" - availability_zone = "eu01-m" + availability_zone = "eu01-1" size = 64 labels = { "key" = "value" diff --git a/examples/resources/stackit_server/resource.tf b/examples/resources/stackit_server/resource.tf new file mode 100644 index 00000000..92a7dbf2 --- /dev/null +++ b/examples/resources/stackit_server/resource.tf @@ -0,0 +1,19 @@ +resource "stackit_server" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "my_server" + boot_volume = { + size = 64 + source_type = "image" + source_id = "IMAGE_ID" + } + initial_networking = { + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + security_group_ids = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] + } + availability_zone = "eu01-1" + labels = { + "key" = "value" + } + machine_type = "t1.1" + keypair_name = "my_key_pair_name" +} \ No newline at end of file diff --git a/examples/resources/stackit_volume/resource.tf b/examples/resources/stackit_volume/resource.tf index ed896f5d..ef88623b 100644 --- a/examples/resources/stackit_volume/resource.tf +++ b/examples/resources/stackit_volume/resource.tf @@ -1,7 +1,7 @@ resource "stackit_volume" "example" { project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "my_volume" - availability_zone = "eu01-m" + availability_zone = "eu01-1" size = 64 labels = { "key" = "value" diff --git a/go.mod b/go.mod index 7548b933..e486fd9a 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.10.0 - github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.5-alpha + github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.6-alpha github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.19.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.19.0 diff --git a/go.sum b/go.sum index 3a253395..036b063f 100644 --- a/go.sum +++ b/go.sum @@ -157,8 +157,8 @@ github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 h1:QIZfs6nJ/l2pOweH1 github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0/go.mod h1:MdZcRbs19s2NLeJmSLSoqTzm9IPIQhE1ZEMpo9gePq0= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.10.0 h1:bqZ1CqiQLcu//4zc859XwYT/IZFjnlV9QeemmDbCPxc= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.10.0/go.mod h1:hEsLOmcqMFG0ftXSYOF8YtDrclkA0E89msGsH69B/BU= -github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.5-alpha h1:myjZNUEsd03lnKoy2rCAdAziGpMqDOJc9xB6OIrSrLA= -github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.5-alpha/go.mod h1:b4KR6r+yWS2hsDkz6ebRqxgadB+ZsAZcG0oDfv5jeaY= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.6-alpha h1:XUYncbRKaqbG76OzoSugfvPHp6+0A86JJxW2T3CLT2E= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.6-alpha/go.mod h1:b4KR6r+yWS2hsDkz6ebRqxgadB+ZsAZcG0oDfv5jeaY= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0 h1:/GwkGMD7ID5hSjdZs1l/Mj8waceCt7oj3TxHgBfEMDQ= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0/go.mod h1:wsO3+vXe1XiKLeCIctWAptaHQZ07Un7kmLTQ+drbj7w= github.com/stackitcloud/stackit-sdk-go/services/logme v0.19.0 h1:KcsF549yXOrm8zlqFCNV+lf2L4zvQuh4L2i3kgdWbOE= diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index c768e151..9fddcb75 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -17,6 +17,11 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) +const ( + serverMachineType = "t1.1" + updatedServerMachineType = "t1.2" +) + // Network resource data var networkResource = map[string]string{ "project_id": testutil.ProjectId, @@ -44,13 +49,26 @@ var networkAreaRouteResource = map[string]string{ var volumeResource = map[string]string{ "project_id": testutil.ProjectId, "availability_zone": "eu01-1", - "name": "name", + "name": fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlpha)), "description": "description", "size": "1", "label1": "value", "performance_class": "storage_premium_perf1", } +// Server resource data +var serverResource = map[string]string{ + "project_id": testutil.ProjectId, + "availability_zone": "eu01-1", + "size": "64", + "source_type": "image", + "source_id": testutil.IaaSImageId, + "name": fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlpha)), + "machine_type": serverMachineType, + "label1": "value", + "user_data": "#!/bin/bash", +} + // Security Group resource data var securityGroupResource = map[string]string{ "project_id": testutil.ProjectId, @@ -131,6 +149,39 @@ func volumeResourceConfig(name, size string) string { ) } +func serverResourceConfig(name, machineType string) string { + return fmt.Sprintf(` + resource "stackit_server" "server" { + project_id = "%s" + availability_zone = "%s" + name = "%s" + machine_type = "%s" + boot_volume = { + size = %s + source_type = "%s" + source_id = "%s" + } + initial_networking = { + network_id = stackit_network.network.network_id + } + labels = { + "label1" = "%s" + } + user_data = "%s" + } + `, + serverResource["project_id"], + serverResource["availability_zone"], + name, + machineType, + serverResource["size"], + serverResource["source_type"], + serverResource["source_id"], + serverResource["label1"], + serverResource["user_data"], + ) +} + func securityGroupResourceConfig(name string) string { return fmt.Sprintf(` resource "stackit_security_group" "security_group" { @@ -149,22 +200,29 @@ func securityGroupResourceConfig(name string) string { ) } -func resourceConfig(name, nameservers, areaname, networkranges string) string { - return fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s", +func testAccNetworkAreaConfig(areaname, networkranges string) string { + return fmt.Sprintf("%s\n\n%s\n\n%s", testutil.IaaSProviderConfig(), - networkResourceConfig(name, nameservers), networkAreaResourceConfig(areaname, networkranges), networkAreaRouteResourceConfig(), ) } -func resourceConfigVolume(name, size string) string { +func testAccVolumeConfig(name, size string) string { return fmt.Sprintf("%s\n\n%s", testutil.IaaSProviderConfig(), volumeResourceConfig(name, size), ) } +func testAccServerConfig(name, nameservers, serverName, machineType string) string { + return fmt.Sprintf("%s\n\n%s\n\n%s", + testutil.IaaSProviderConfig(), + networkResourceConfig(name, nameservers), + serverResourceConfig(serverName, machineType), + ) +} + func resourceConfigSecurityGroup(name string) string { return fmt.Sprintf("%s\n\n%s", testutil.IaaSProviderConfig(), @@ -172,31 +230,19 @@ func resourceConfigSecurityGroup(name string) string { ) } -func TestAccIaaS(t *testing.T) { +func TestAccNetworkArea(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - CheckDestroy: testAccCheckIaaSDestroy, + CheckDestroy: testAccCheckNetworkAreaDestroy, Steps: []resource.TestStep{ // Creation { - Config: resourceConfig( - networkResource["name"], - fmt.Sprintf( - "[%q]", - networkResource["nameserver0"], - ), + Config: testAccNetworkAreaConfig( networkAreaResource["name"], networkAreaResource["networkrange0"], ), Check: resource.ComposeAggregateTestCheckFunc( - // Instance - resource.TestCheckResourceAttr("stackit_network.network", "project_id", networkResource["project_id"]), - resource.TestCheckResourceAttrSet("stackit_network.network", "network_id"), - resource.TestCheckResourceAttr("stackit_network.network", "name", networkResource["name"]), - resource.TestCheckResourceAttr("stackit_network.network", "nameservers.#", "1"), - resource.TestCheckResourceAttr("stackit_network.network", "nameservers.0", networkResource["nameserver0"]), - // Network Area resource.TestCheckResourceAttr("stackit_network_area.network_area", "organization_id", networkAreaResource["organization_id"]), resource.TestCheckResourceAttrSet("stackit_network_area.network_area", "network_area_id"), @@ -223,12 +269,7 @@ func TestAccIaaS(t *testing.T) { { Config: fmt.Sprintf(` %s - - data "stackit_network" "network" { - project_id = stackit_network.network.project_id - network_id = stackit_network.network.network_id - } - + data "stackit_network_area" "network_area" { organization_id = stackit_network_area.network_area.organization_id network_area_id = stackit_network_area.network_area.network_area_id @@ -240,25 +281,12 @@ func TestAccIaaS(t *testing.T) { network_area_route_id = stackit_network_area_route.network_area_route.network_area_route_id } `, - resourceConfig( - networkResource["name"], - fmt.Sprintf( - "[%q]", - networkResource["nameserver0"], - ), + testAccNetworkAreaConfig( networkAreaResource["name"], networkAreaResource["networkrange0"], ), ), Check: resource.ComposeAggregateTestCheckFunc( - // Instance - resource.TestCheckResourceAttr("data.stackit_network.network", "project_id", networkResource["project_id"]), - resource.TestCheckResourceAttrPair( - "stackit_network.network", "network_id", - "data.stackit_network.network", "network_id", - ), - resource.TestCheckResourceAttr("data.stackit_network.network", "name", networkResource["name"]), - resource.TestCheckResourceAttr("data.stackit_network.network", "nameservers.0", networkResource["nameserver0"]), // Network area resource.TestCheckResourceAttr("data.stackit_network_area.network_area", "organization_id", networkAreaResource["organization_id"]), @@ -285,23 +313,6 @@ func TestAccIaaS(t *testing.T) { ), }, // Import - { - ResourceName: "stackit_network.network", - ImportStateIdFunc: func(s *terraform.State) (string, error) { - r, ok := s.RootModule().Resources["stackit_network.network"] - if !ok { - return "", fmt.Errorf("couldn't find resource stackit_network.network") - } - networkId, ok := r.Primary.Attributes["network_id"] - if !ok { - return "", fmt.Errorf("couldn't find attribute network_id") - } - return fmt.Sprintf("%s,%s", testutil.ProjectId, networkId), nil - }, - ImportState: true, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"ipv4_prefix_length"}, // Field is not returned by the API - }, { ResourceName: "stackit_network_area.network_area", ImportStateIdFunc: func(s *terraform.State) (string, error) { @@ -340,25 +351,11 @@ func TestAccIaaS(t *testing.T) { }, // Update { - Config: resourceConfig( - fmt.Sprintf("%s-updated", networkResource["name"]), - fmt.Sprintf( - "[%q, %q]", - networkResource["nameserver0"], - networkResource["nameserver1"], - ), + Config: testAccNetworkAreaConfig( fmt.Sprintf("%s-updated", networkAreaResource["name"]), networkAreaResource["networkrange0"], ), Check: resource.ComposeAggregateTestCheckFunc( - // Instance - resource.TestCheckResourceAttr("stackit_network.network", "project_id", networkResource["project_id"]), - resource.TestCheckResourceAttrSet("stackit_network.network", "network_id"), - resource.TestCheckResourceAttr("stackit_network.network", "name", fmt.Sprintf("%s-updated", networkResource["name"])), - resource.TestCheckResourceAttr("stackit_network.network", "nameservers.#", "2"), - resource.TestCheckResourceAttr("stackit_network.network", "nameservers.0", networkResource["nameserver0"]), - resource.TestCheckResourceAttr("stackit_network.network", "nameservers.1", networkResource["nameserver1"]), - // Network area resource.TestCheckResourceAttr("stackit_network_area.network_area", "organization_id", networkAreaResource["organization_id"]), resource.TestCheckResourceAttrSet("stackit_network_area.network_area", "network_area_id"), @@ -372,7 +369,7 @@ func TestAccIaaS(t *testing.T) { }) } -func TestAccIaaSVolume(t *testing.T) { +func TestAccVolume(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, CheckDestroy: testAccCheckIaaSVolumeDestroy, @@ -380,13 +377,12 @@ func TestAccIaaSVolume(t *testing.T) { // Creation { - Config: resourceConfigVolume(volumeResource["name"], volumeResource["size"]), + Config: testAccVolumeConfig(volumeResource["name"], volumeResource["size"]), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("stackit_volume.volume", "project_id", volumeResource["project_id"]), resource.TestCheckResourceAttrSet("stackit_volume.volume", "volume_id"), resource.TestCheckResourceAttr("stackit_volume.volume", "name", volumeResource["name"]), resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), - resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), resource.TestCheckResourceAttr("stackit_volume.volume", "labels.label1", volumeResource["label1"]), resource.TestCheckResourceAttr("stackit_volume.volume", "description", volumeResource["description"]), resource.TestCheckResourceAttr("stackit_volume.volume", "performance_class", volumeResource["performance_class"]), @@ -403,7 +399,7 @@ func TestAccIaaSVolume(t *testing.T) { volume_id = stackit_volume.volume.volume_id } `, - resourceConfigVolume(volumeResource["name"], volumeResource["size"]), + testAccVolumeConfig(volumeResource["name"], volumeResource["size"]), ), Check: resource.ComposeAggregateTestCheckFunc( // Instance @@ -440,7 +436,7 @@ func TestAccIaaSVolume(t *testing.T) { }, // Update { - Config: resourceConfigVolume( + Config: testAccVolumeConfig( fmt.Sprintf("%s-updated", volumeResource["name"]), "10", ), @@ -449,7 +445,6 @@ func TestAccIaaSVolume(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_volume.volume", "volume_id"), resource.TestCheckResourceAttr("stackit_volume.volume", "name", fmt.Sprintf("%s-updated", volumeResource["name"])), resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), - resource.TestCheckResourceAttr("stackit_volume.volume", "availability_zone", volumeResource["availability_zone"]), resource.TestCheckResourceAttr("stackit_volume.volume", "labels.label1", volumeResource["label1"]), resource.TestCheckResourceAttr("stackit_volume.volume", "description", volumeResource["description"]), resource.TestCheckResourceAttr("stackit_volume.volume", "performance_class", volumeResource["performance_class"]), @@ -461,6 +456,160 @@ func TestAccIaaSVolume(t *testing.T) { }) } +func TestAccServer(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckServerDestroy, + Steps: []resource.TestStep{ + + // Creation + { + Config: testAccServerConfig( + networkResource["name"], + fmt.Sprintf( + "[%q]", + networkResource["nameserver0"], + ), + serverResource["name"], + serverResource["machine_type"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + + // Network + resource.TestCheckResourceAttr("stackit_network.network", "project_id", networkResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_network.network", "network_id"), + resource.TestCheckResourceAttr("stackit_network.network", "name", networkResource["name"]), + resource.TestCheckResourceAttr("stackit_network.network", "nameservers.#", "1"), + resource.TestCheckResourceAttr("stackit_network.network", "nameservers.0", networkResource["nameserver0"]), + + // Server + resource.TestCheckResourceAttr("stackit_server.server", "project_id", serverResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_server.server", "server_id"), + resource.TestCheckResourceAttr("stackit_server.server", "name", serverResource["name"]), + resource.TestCheckResourceAttr("stackit_server.server", "availability_zone", serverResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_server.server", "machine_type", serverResource["machine_type"]), + resource.TestCheckResourceAttr("stackit_server.server", "labels.label1", serverResource["label1"]), + resource.TestCheckResourceAttr("stackit_server.server", "user_data", serverResource["user_data"]), + ), + }, + // Data source + { + Config: fmt.Sprintf(` + %s + + data "stackit_network" "network" { + project_id = stackit_network.network.project_id + network_id = stackit_network.network.network_id + } + + data "stackit_server" "server" { + project_id = stackit_server.server.project_id + server_id = stackit_server.server.server_id + } + `, + testAccServerConfig( + networkResource["name"], + fmt.Sprintf( + "[%q]", + networkResource["nameserver0"], + ), + serverResource["name"], + serverResource["machine_type"], + ), + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("data.stackit_network.network", "project_id", networkResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_network.network", "network_id", + "data.stackit_network.network", "network_id", + ), + resource.TestCheckResourceAttr("data.stackit_network.network", "name", networkResource["name"]), + resource.TestCheckResourceAttr("data.stackit_network.network", "nameservers.0", networkResource["nameserver0"]), + + // Server + resource.TestCheckResourceAttr("data.stackit_server.server", "project_id", serverResource["project_id"]), + resource.TestCheckResourceAttrPair( + "stackit_server.server", "server_id", + "data.stackit_server.server", "server_id", + ), + resource.TestCheckResourceAttr("data.stackit_server.server", "name", serverResource["name"]), + resource.TestCheckResourceAttr("data.stackit_server.server", "availability_zone", serverResource["availability_zone"]), + resource.TestCheckResourceAttr("data.stackit_server.server", "machine_type", serverResource["machine_type"]), + resource.TestCheckResourceAttr("data.stackit_server.server", "labels.label1", serverResource["label1"]), + ), + }, + // Import + { + ResourceName: "stackit_network.network", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_network.network"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_network.network") + } + networkId, ok := r.Primary.Attributes["network_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, networkId), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"ipv4_prefix_length"}, // Field is not returned by the API + }, + { + ResourceName: "stackit_server.server", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_server.server"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_server.server") + } + serverId, ok := r.Primary.Attributes["server_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute server_id") + } + return fmt.Sprintf("%s,%s", testutil.ProjectId, serverId), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"initial_networking", "boot_volume", "user_data"}, // Field is not mapped as it is only relevant on creation + }, + // Update + { + Config: testAccServerConfig( + fmt.Sprintf("%s-updated", networkResource["name"]), + fmt.Sprintf( + "[%q, %q]", + networkResource["nameserver0"], + networkResource["nameserver1"], + ), + fmt.Sprintf("%s-updated", serverResource["name"]), + updatedServerMachineType, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Network + resource.TestCheckResourceAttr("stackit_network.network", "project_id", networkResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_network.network", "network_id"), + resource.TestCheckResourceAttr("stackit_network.network", "name", fmt.Sprintf("%s-updated", networkResource["name"])), + resource.TestCheckResourceAttr("stackit_network.network", "nameservers.#", "2"), + resource.TestCheckResourceAttr("stackit_network.network", "nameservers.0", networkResource["nameserver0"]), + resource.TestCheckResourceAttr("stackit_network.network", "nameservers.1", networkResource["nameserver1"]), + + // Server + resource.TestCheckResourceAttr("stackit_server.server", "project_id", serverResource["project_id"]), + resource.TestCheckResourceAttrSet("stackit_server.server", "server_id"), + resource.TestCheckResourceAttr("stackit_server.server", "name", fmt.Sprintf("%s-updated", serverResource["name"])), + resource.TestCheckResourceAttr("stackit_server.server", "availability_zone", serverResource["availability_zone"]), + resource.TestCheckResourceAttr("stackit_server.server", "machine_type", updatedServerMachineType), + resource.TestCheckResourceAttr("stackit_server.server", "labels.label1", serverResource["label1"]), + resource.TestCheckResourceAttr("stackit_server.server", "user_data", serverResource["user_data"]), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + func TestAccIaaSSecurityGroup(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, @@ -537,7 +686,7 @@ func TestAccIaaSSecurityGroup(t *testing.T) { }) } -func testAccCheckIaaSDestroy(s *terraform.State) error { +func testAccCheckNetworkAreaDestroy(s *terraform.State) error { ctx := context.Background() var client *iaas.APIClient var err error @@ -554,34 +703,6 @@ func testAccCheckIaaSDestroy(s *terraform.State) error { return fmt.Errorf("creating client: %w", err) } - networksToDestroy := []string{} - for _, rs := range s.RootModule().Resources { - if rs.Type != "stackit_network" { - continue - } - // network terraform ID: "[project_id],[network_id]" - networkId := strings.Split(rs.Primary.ID, core.Separator)[1] - networksToDestroy = append(networksToDestroy, networkId) - } - - networksResp, err := client.ListNetworksExecute(ctx, testutil.ProjectId) - if err != nil { - return fmt.Errorf("getting networksResp: %w", err) - } - - networks := *networksResp.Items - for i := range networks { - if networks[i].NetworkId == nil { - continue - } - if utils.Contains(networksToDestroy, *networks[i].NetworkId) { - err := client.DeleteNetworkExecute(ctx, testutil.ProjectId, *networks[i].NetworkId) - if err != nil { - return fmt.Errorf("destroying network %s during CheckDestroy: %w", *networks[i].NetworkId, err) - } - } - } - // network areas networkAreasToDestroy := []string{} for _, rs := range s.RootModule().Resources { @@ -659,6 +780,94 @@ func testAccCheckIaaSVolumeDestroy(s *terraform.State) error { return nil } +func testAccCheckServerDestroy(s *terraform.State) error { + ctx := context.Background() + var alphaClient *iaasalpha.APIClient + var client *iaas.APIClient + var err error + var alphaErr error + if testutil.IaaSCustomEndpoint == "" { + alphaClient, alphaErr = iaasalpha.NewAPIClient( + config.WithRegion("eu01"), + ) + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } else { + alphaClient, alphaErr = iaasalpha.NewAPIClient( + config.WithEndpoint(testutil.IaaSCustomEndpoint), + ) + client, err = iaas.NewAPIClient( + config.WithRegion("eu01"), + ) + } + if err != nil || alphaErr != nil { + return fmt.Errorf("creating client: %w, %w", err, alphaErr) + } + + // Servers + + serversToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_server" { + continue + } + // server terraform ID: "[project_id],[server_id]" + serverId := strings.Split(rs.Primary.ID, core.Separator)[1] + serversToDestroy = append(serversToDestroy, serverId) + } + + serversResp, err := alphaClient.ListServersExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting serversResp: %w", err) + } + + servers := *serversResp.Items + for i := range servers { + if servers[i].Id == nil { + continue + } + if utils.Contains(serversToDestroy, *servers[i].Id) { + err := alphaClient.DeleteServerExecute(ctx, testutil.ProjectId, *servers[i].Id) + if err != nil { + return fmt.Errorf("destroying server %s during CheckDestroy: %w", *servers[i].Id, err) + } + } + } + + // Networks + + networksToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_network" { + continue + } + // network terraform ID: "[project_id],[network_id]" + networkId := strings.Split(rs.Primary.ID, core.Separator)[1] + networksToDestroy = append(networksToDestroy, networkId) + } + + networksResp, err := client.ListNetworksExecute(ctx, testutil.ProjectId) + if err != nil { + return fmt.Errorf("getting networksResp: %w", err) + } + + networks := *networksResp.Items + for i := range networks { + if networks[i].NetworkId == nil { + continue + } + if utils.Contains(networksToDestroy, *networks[i].NetworkId) { + err := client.DeleteNetworkExecute(ctx, testutil.ProjectId, *networks[i].NetworkId) + if err != nil { + return fmt.Errorf("destroying network %s during CheckDestroy: %w", *networks[i].NetworkId, err) + } + } + } + + return nil +} + func testAccCheckIaaSSecurityGroupDestroy(s *terraform.State) error { ctx := context.Background() var client *iaasalpha.APIClient diff --git a/stackit/internal/services/iaas/server/datasource.go b/stackit/internal/services/iaas/server/datasource.go new file mode 100644 index 00000000..55fa1285 --- /dev/null +++ b/stackit/internal/services/iaas/server/datasource.go @@ -0,0 +1,244 @@ +package server + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// serverDataSourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var serverDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &serverDataSource{} +) + +// NewServerDataSource is a helper function to simplify the provider implementation. +func NewServerDataSource() datasource.DataSource { + return &serverDataSource{} +} + +// serverDataSource is the data source implementation. +type serverDataSource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the data source type name. +func (d *serverDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_server" +} + +func (d *serverDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + var apiClient *iaasalpha.APIClient + var err error + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !serverDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_server", "data source") + if resp.Diagnostics.HasError() { + return + } + serverDataSourceBetaCheckDone = true + } + + if providerData.IaaSCustomEndpoint != "" { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err)) + return + } + + d.client = apiClient + tflog.Info(ctx, "iaasalpha client configured") +} + +// Schema defines the schema for the datasource. +func (r *serverDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Server datasource schema. Must have a `region` specified in the provider configuration."), + Description: "Server datasource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`server_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the server is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the server.", + Computed: true, + }, + "machine_type": schema.StringAttribute{ + MarkdownDescription: "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)", + Computed: true, + }, + "initial_networking": schema.SingleNestedAttribute{ + Description: "The initial networking setup for the server. A network ID or a list of network interfaces IDs can be provided", + Computed: true, + Attributes: map[string]schema.Attribute{ + "network_id": schema.StringAttribute{ + Description: "The network ID", + Computed: true, + }, + "network_interface_ids": schema.ListAttribute{ + ElementType: types.StringType, + Description: "List of network interface IDs", + Computed: true, + }, + "security_group_ids": schema.ListAttribute{ + ElementType: types.StringType, + Description: "List of security groups the server is associated to", + Computed: true, + }, + }, + }, + "availability_zone": schema.StringAttribute{ + Description: "The availability zone of the server.", + Computed: true, + }, + "boot_volume": schema.SingleNestedAttribute{ + Description: "The boot volume for the server", + Computed: true, + Attributes: map[string]schema.Attribute{ + "performance_class": schema.StringAttribute{ + Description: "The performance class of the server.", + Computed: true, + }, + "size": schema.Int64Attribute{ + Description: "The size of the boot volume in GB.", + Computed: true, + }, + "type": schema.StringAttribute{ + Description: "The type of the source. " + utils.SupportedValuesDocumentation(supportedSourceTypes), + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The ID of the source, either image ID or volume ID", + Computed: true, + }, + }, + }, + "image_id": schema.StringAttribute{ + Description: "The image ID to be used for an ephemeral disk on the server.", + Computed: true, + }, + "keypair_name": schema.StringAttribute{ + Description: "The name of the keypair used during server creation.", + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Computed: true, + }, + "server_group": schema.StringAttribute{ + Description: "The server group the server is assigned to.", + Computed: true, + }, + "user_data": schema.StringAttribute{ + Description: "User data that is passed via cloud-init to the server.", + Computed: true, + }, + "created_at": schema.StringAttribute{ + Description: "Date-time when the server was created", + Computed: true, + }, + "launched_at": schema.StringAttribute{ + Description: "Date-time when the server was launched", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + Description: "Date-time when the server was updated", + Computed: true, + }, + }, + } +} + +// // Read refreshes the Terraform state with the latest data. +func (r *serverDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + serverResp, err := r.client.GetServer(ctx, projectId, serverId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, serverResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "server read") +} diff --git a/stackit/internal/services/iaas/server/resource.go b/stackit/internal/services/iaas/server/resource.go new file mode 100644 index 00000000..abaed25b --- /dev/null +++ b/stackit/internal/services/iaas/server/resource.go @@ -0,0 +1,795 @@ +package server + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &serverResource{} + _ resource.ResourceWithConfigure = &serverResource{} + _ resource.ResourceWithImportState = &serverResource{} + + supportedSourceTypes = []string{"volume", "image"} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + ServerId types.String `tfsdk:"server_id"` + MachineType types.String `tfsdk:"machine_type"` + Name types.String `tfsdk:"name"` + InitialNetworking types.Object `tfsdk:"initial_networking"` + AvailabilityZone types.String `tfsdk:"availability_zone"` + BootVolume types.Object `tfsdk:"boot_volume"` + ImageId types.String `tfsdk:"image_id"` + KeypairName types.String `tfsdk:"keypair_name"` + Labels types.Map `tfsdk:"labels"` + ServerGroup types.String `tfsdk:"server_group"` + UserData types.String `tfsdk:"user_data"` + CreatedAt types.String `tfsdk:"created_at"` + LaunchedAt types.String `tfsdk:"launched_at"` + UpdatedAt types.String `tfsdk:"updated_at"` +} + +// Struct corresponding to Model.InitialNetwork +type initialNetworkModel struct { + NetworkId types.String `tfsdk:"network_id"` + NetworkInterfaceIds types.List `tfsdk:"network_interface_ids"` + SecurityGroupIds types.List `tfsdk:"security_group_ids"` +} + +// Types corresponding to initialNetworkModel +var initialNetworkTypes = map[string]attr.Type{ + "network_id": basetypes.StringType{}, + "network_interface_ids": basetypes.ListType{ElemType: types.StringType}, + "security_group_ids": basetypes.ListType{ElemType: types.StringType}, +} + +// Struct corresponding to Model.BootVolume +type bootVolumeModel struct { + PerformanceClass types.String `tfsdk:"performance_class"` + Size types.Int64 `tfsdk:"size"` + SourceType types.String `tfsdk:"source_type"` + SourceId types.String `tfsdk:"source_id"` +} + +// Types corresponding to bootVolumeModel +var bootVolumeTypes = map[string]attr.Type{ + "performance_class": basetypes.StringType{}, + "size": basetypes.Int64Type{}, + "source_type": basetypes.StringType{}, + "source_id": basetypes.StringType{}, +} + +// NewServerResource is a helper function to simplify the provider implementation. +func NewServerResource() resource.Resource { + return &serverResource{} +} + +// serverResource is the resource implementation. +type serverResource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the resource type name. +func (r *serverResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_server" +} + +// ConfigValidators validates the resource configuration +func (r *serverResource) ConfigValidators(_ context.Context) []resource.ConfigValidator { + return []resource.ConfigValidator{ + resourcevalidator.AtLeastOneOf( + path.MatchRoot("image_id"), + path.MatchRoot("boot_volume"), + ), + } +} + +// Configure adds the provider configured client to the resource. +func (r *serverResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_server", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "iaasalpha client configured") +} + +// Schema defines the schema for the resource. +func (r *serverResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Server resource schema. Must have a `region` specified in the provider configuration."), + Description: "Server resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`server_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the server is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "server_id": schema.StringAttribute{ + Description: "The server ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the server.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "machine_type": schema.StringAttribute{ + MarkdownDescription: "Name of the type of the machine for the server. Possible values are documented in [Virtual machine flavors](https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html)", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "initial_networking": schema.SingleNestedAttribute{ + Description: "The initial networking setup for the server. A network ID or a list of network interfaces IDs can be provided", + Optional: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), + }, + Attributes: map[string]schema.Attribute{ + "network_id": schema.StringAttribute{ + Description: "The network ID", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRoot("initial_networking").AtName("network_interface_ids"), + ), + validate.UUID(), + }, + }, + "security_group_ids": schema.ListAttribute{ + ElementType: types.StringType, + Description: "List of security groups the initial network is assigned to", + Optional: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + ), + listvalidator.ConflictsWith( + path.MatchRoot("initial_networking").AtName("network_interface_ids"), + ), + listvalidator.AlsoRequires( + path.MatchRoot("initial_networking").AtName("network_id"), + ), + }, + }, + "network_interface_ids": schema.ListAttribute{ + ElementType: types.StringType, + Description: "List of network interface IDs", + Optional: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + Validators: []validator.List{ + listvalidator.ConflictsWith( + path.MatchRoot("initial_networking").AtName("network_id"), + ), + listvalidator.ValueStringsAre( + validate.UUID(), + ), + }, + }, + }, + Validators: []validator.Object{ + objectvalidator.AtLeastOneOf( + path.MatchRoot("initial_networking").AtName("network_id"), + path.MatchRoot("initial_networking").AtName("network_interface_ids"), + ), + }, + }, + "availability_zone": schema.StringAttribute{ + Description: "The availability zone of the server.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Optional: true, + Computed: true, + }, + "boot_volume": schema.SingleNestedAttribute{ + Description: "The boot volume for the server", + Optional: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), + }, + Attributes: map[string]schema.Attribute{ + "performance_class": schema.StringAttribute{ + Description: "The performance class of the server.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "size": schema.Int64Attribute{ + Description: "The size of the boot volume in GB. Must be provided when `source_type` is `image`.", + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, + "source_type": schema.StringAttribute{ + Description: "The type of the source. " + utils.SupportedValuesDocumentation(supportedSourceTypes), + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "source_id": schema.StringAttribute{ + Description: "The ID of the source, either image ID or volume ID", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + }, + "image_id": schema.StringAttribute{ + Description: "The image ID to be used for an ephemeral disk on the server.", + Optional: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "keypair_name": schema.StringAttribute{ + Description: "The name of the keypair used during server creation.", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a resource container", + ElementType: types.StringType, + Optional: true, + }, + "server_group": schema.StringAttribute{ + Description: "The server group the server is assigned to.", + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(36), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`), + "must match expression"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "user_data": schema.StringAttribute{ + Description: "User data that is passed via cloud-init to the server.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "created_at": schema.StringAttribute{ + Description: "Date-time when the server was created", + Computed: true, + }, + "launched_at": schema.StringAttribute{ + Description: "Date-time when the server was launched", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + Description: "Date-time when the server was updated", + Computed: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *serverResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new server + + server, err := r.client.CreateServer(ctx, projectId).CreateServerPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("Calling API: %v", err)) + return + } + + serverId := *server.Id + server, err = wait.CreateServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("server creation waiting: %v", err)) + return + } + + ctx = tflog.SetField(ctx, "server_id", serverId) + + // Map response body to schema + err = mapFields(ctx, server, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating server", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Server created") +} + +// // Read refreshes the Terraform state with the latest data. +func (r *serverResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + serverResp, err := r.client.GetServer(ctx, projectId, serverId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, serverResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading server", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "server read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *serverResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing server + updatedServer, err := r.client.V1alpha1UpdateServer(ctx, projectId, serverId).V1alpha1UpdateServerPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Update machine type + modelMachineType := conversion.StringValueToPointer(model.MachineType) + if modelMachineType != nil && updatedServer.MachineType != nil && *modelMachineType != *updatedServer.MachineType { + payload := iaasalpha.ResizeServerPayload{ + MachineType: modelMachineType, + } + err := r.client.ResizeServer(ctx, projectId, serverId).ResizeServerPayload(payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Resizing the server, calling API: %v", err)) + } + + _, err = wait.ResizeServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("server resize waiting: %v", err)) + return + } + // Update server model because the API doesn't return a server object as response + updatedServer.MachineType = modelMachineType + } + + err = mapFields(ctx, updatedServer, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating server", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "server updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *serverResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + serverId := model.ServerId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + // Delete existing server + err := r.client.DeleteServer(ctx, projectId, serverId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting server", fmt.Sprintf("Calling API: %v", err)) + return + } + _, err = wait.DeleteServerWaitHandler(ctx, r.client, projectId, serverId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting server", fmt.Sprintf("server deletion waiting: %v", err)) + return + } + + tflog.Info(ctx, "server deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,server_id +func (r *serverResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing server", + fmt.Sprintf("Expected import identifier with format: [project_id],[server_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + serverId := idParts[1] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "server_id", serverId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("server_id"), serverId)...) + tflog.Info(ctx, "server state imported") +} + +func mapFields(ctx context.Context, serverResp *iaasalpha.Server, model *Model) error { + if serverResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var serverId string + if model.ServerId.ValueString() != "" { + serverId = model.ServerId.ValueString() + } else if serverResp.Id != nil { + serverId = *serverResp.Id + } else { + return fmt.Errorf("Server id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + serverId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags)) + } + if serverResp.Labels != nil && len(*serverResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *serverResp.Labels) + if diags.HasError() { + return fmt.Errorf("convert labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + var createdAt basetypes.StringValue + if serverResp.CreatedAt != nil { + createdAtValue := *serverResp.CreatedAt + createdAt = types.StringValue(createdAtValue.Format(time.RFC3339)) + } + var updatedAt basetypes.StringValue + if serverResp.UpdatedAt != nil { + updatedAtValue := *serverResp.UpdatedAt + updatedAt = types.StringValue(updatedAtValue.Format(time.RFC3339)) + } + var launchedAt basetypes.StringValue + if serverResp.LaunchedAt != nil { + launchedAtValue := *serverResp.LaunchedAt + launchedAt = types.StringValue(launchedAtValue.Format(time.RFC3339)) + } + + model.ServerId = types.StringValue(serverId) + model.MachineType = types.StringPointerValue(serverResp.MachineType) + model.AvailabilityZone = types.StringPointerValue(serverResp.AvailabilityZone) + model.Name = types.StringPointerValue(serverResp.Name) + model.Labels = labels + model.ImageId = types.StringPointerValue(serverResp.Image) + model.KeypairName = types.StringPointerValue(serverResp.Keypair) + model.ServerGroup = types.StringPointerValue(serverResp.ServerGroup) + model.CreatedAt = createdAt + model.UpdatedAt = updatedAt + model.LaunchedAt = launchedAt + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaasalpha.CreateServerPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + var bootVolume = &bootVolumeModel{} + if !(model.BootVolume.IsNull() || model.BootVolume.IsUnknown()) { + diags := model.BootVolume.As(ctx, bootVolume, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("convert boot volume object to struct: %w", core.DiagsToError(diags)) + } + } + + var initialNetwork = &initialNetworkModel{} + if !(model.InitialNetworking.IsNull() || model.InitialNetworking.IsUnknown()) { + diags := model.InitialNetworking.As(ctx, initialNetwork, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return nil, fmt.Errorf("convert initial network object to struct: %w", core.DiagsToError(diags)) + } + } + + labels, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + var bootVolumePayload *iaasalpha.CreateServerPayloadBootVolume + if !bootVolume.SourceId.IsNull() && !bootVolume.SourceType.IsNull() { + bootVolumePayload = &iaasalpha.CreateServerPayloadBootVolume{ + PerformanceClass: conversion.StringValueToPointer(bootVolume.PerformanceClass), + Size: conversion.Int64ValueToPointer(bootVolume.Size), + Source: &iaasalpha.BootVolumeSource{ + Id: conversion.StringValueToPointer(bootVolume.SourceId), + Type: conversion.StringValueToPointer(bootVolume.SourceType), + }, + } + } + + var initialNetworkPayload *iaasalpha.CreateServerPayloadNetworking + var securityGroups *[]string + if !initialNetwork.NetworkId.IsNull() { + initialNetworkPayload = &iaasalpha.CreateServerPayloadNetworking{ + CreateServerNetworking: &iaasalpha.CreateServerNetworking{ + NetworkId: conversion.StringValueToPointer(initialNetwork.NetworkId), + }, + } + + securityGroups, err = conversion.StringListToPointer(initialNetwork.SecurityGroupIds) + if err != nil { + return nil, fmt.Errorf("converting list of security groups to string list pointer: %w", err) + } + } else if !initialNetwork.NetworkInterfaceIds.IsNull() { + nicIds, err := conversion.StringListToPointer(initialNetwork.NetworkInterfaceIds) + if err != nil { + return nil, fmt.Errorf("converting list of network interface IDs to string list pointer: %w", err) + } + initialNetworkPayload = &iaasalpha.CreateServerPayloadNetworking{ + CreateServerNetworkingWithNics: &iaasalpha.CreateServerNetworkingWithNics{ + NicIds: nicIds, + }, + } + } + + var userData *string + if !model.UserData.IsNull() && !model.UserData.IsUnknown() { + encodedUserData := base64.StdEncoding.EncodeToString([]byte(model.UserData.ValueString())) + userData = &encodedUserData + } + + return &iaasalpha.CreateServerPayload{ + AvailabilityZone: conversion.StringValueToPointer(model.AvailabilityZone), + BootVolume: bootVolumePayload, + Image: conversion.StringValueToPointer(model.ImageId), + Keypair: conversion.StringValueToPointer(model.KeypairName), + Networking: initialNetworkPayload, + SecurityGroups: securityGroups, + Labels: &labels, + Name: conversion.StringValueToPointer(model.Name), + MachineType: conversion.StringValueToPointer(model.MachineType), + UserData: userData, + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaasalpha.V1alpha1UpdateServerPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + labels, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("converting to Go map: %w", err) + } + + return &iaasalpha.V1alpha1UpdateServerPayload{ + Name: conversion.StringValueToPointer(model.Name), + Labels: &labels, + }, nil +} diff --git a/stackit/internal/services/iaas/server/resource_test.go b/stackit/internal/services/iaas/server/resource_test.go new file mode 100644 index 00000000..b809a276 --- /dev/null +++ b/stackit/internal/services/iaas/server/resource_test.go @@ -0,0 +1,322 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +const ( + userData = "user_data" + base64EncodedUserData = "dXNlcl9kYXRh" + testTimestampValue = "2006-01-02T15:04:05Z" +) + +func testTimestamp() time.Time { + timestamp, _ := time.Parse(time.RFC3339, testTimestampValue) + return timestamp +} + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaasalpha.Server + expected Model + isValid bool + }{ + { + "default_values", + Model{ + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + }, + &iaasalpha.Server{ + Id: utils.Ptr("sid"), + }, + Model{ + Id: types.StringValue("pid,sid"), + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + Name: types.StringNull(), + AvailabilityZone: types.StringNull(), + Labels: types.MapNull(types.StringType), + ImageId: types.StringNull(), + KeypairName: types.StringNull(), + ServerGroup: types.StringNull(), + UserData: types.StringNull(), + CreatedAt: types.StringNull(), + UpdatedAt: types.StringNull(), + LaunchedAt: types.StringNull(), + }, + true, + }, + { + "simple_values", + Model{ + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + }, + &iaasalpha.Server{ + Id: utils.Ptr("sid"), + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Image: utils.Ptr("image_id"), + Keypair: utils.Ptr("keypair_name"), + ServerGroup: utils.Ptr("group_id"), + CreatedAt: utils.Ptr(testTimestamp()), + UpdatedAt: utils.Ptr(testTimestamp()), + LaunchedAt: utils.Ptr(testTimestamp()), + }, + Model{ + Id: types.StringValue("pid,sid"), + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + ImageId: types.StringValue("image_id"), + KeypairName: types.StringValue("keypair_name"), + ServerGroup: types.StringValue("group_id"), + CreatedAt: types.StringValue(testTimestampValue), + UpdatedAt: types.StringValue(testTimestampValue), + LaunchedAt: types.StringValue(testTimestampValue), + }, + true, + }, + { + "empty_labels", + Model{ + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + }, + &iaasalpha.Server{ + Id: utils.Ptr("sid"), + }, + Model{ + Id: types.StringValue("pid,sid"), + ProjectId: types.StringValue("pid"), + ServerId: types.StringValue("sid"), + Name: types.StringNull(), + AvailabilityZone: types.StringNull(), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{}), + ImageId: types.StringNull(), + KeypairName: types.StringNull(), + ServerGroup: types.StringNull(), + UserData: types.StringNull(), + CreatedAt: types.StringNull(), + UpdatedAt: types.StringNull(), + LaunchedAt: types.StringNull(), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaasalpha.Server{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.CreateServerPayload + isValid bool + }{ + { + "create_with_network", + &Model{ + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + BootVolume: types.ObjectValueMust(bootVolumeTypes, map[string]attr.Value{ + "performance_class": types.StringValue("class"), + "size": types.Int64Value(1), + "source_type": types.StringValue("type"), + "source_id": types.StringValue("id"), + }), + InitialNetworking: types.ObjectValueMust(initialNetworkTypes, map[string]attr.Value{ + "network_id": types.StringValue("nid"), + "security_group_ids": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("group1"), + types.StringValue("group2"), + }), + "network_interface_ids": types.ListNull(types.StringType), + }), + ImageId: types.StringValue("image"), + KeypairName: types.StringValue("keypair"), + MachineType: types.StringValue("machine_type"), + UserData: types.StringValue(userData), + }, + &iaasalpha.CreateServerPayload{ + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Networking: &iaasalpha.CreateServerPayloadNetworking{ + CreateServerNetworking: &iaasalpha.CreateServerNetworking{ + NetworkId: utils.Ptr("nid"), + }, + }, + SecurityGroups: utils.Ptr([]string{"group1", "group2"}), + BootVolume: &iaasalpha.CreateServerPayloadBootVolume{ + PerformanceClass: utils.Ptr("class"), + Size: utils.Ptr(int64(1)), + Source: &iaasalpha.BootVolumeSource{ + Type: utils.Ptr("type"), + Id: utils.Ptr("id"), + }, + }, + Image: utils.Ptr("image"), + Keypair: utils.Ptr("keypair"), + MachineType: utils.Ptr("machine_type"), + UserData: utils.Ptr(base64EncodedUserData), + }, + true, + }, + { + "create_with_network_interface_ids", + &Model{ + Name: types.StringValue("name"), + AvailabilityZone: types.StringValue("zone"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + InitialNetworking: types.ObjectValueMust(initialNetworkTypes, map[string]attr.Value{ + "network_id": types.StringNull(), + "security_group_ids": types.ListNull(types.StringType), + "network_interface_ids": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("nic1"), + types.StringValue("nic2"), + }), + }), + ImageId: types.StringValue("image"), + KeypairName: types.StringValue("keypair"), + MachineType: types.StringValue("machine_type"), + UserData: types.StringValue(userData), + }, + &iaasalpha.CreateServerPayload{ + Name: utils.Ptr("name"), + AvailabilityZone: utils.Ptr("zone"), + Labels: &map[string]interface{}{ + "key": "value", + }, + Networking: &iaasalpha.CreateServerPayloadNetworking{ + CreateServerNetworkingWithNics: &iaasalpha.CreateServerNetworkingWithNics{ + NicIds: utils.Ptr([]string{"nic1", "nic2"}), + }, + }, + Image: utils.Ptr("image"), + Keypair: utils.Ptr("keypair"), + MachineType: utils.Ptr("machine_type"), + UserData: utils.Ptr(base64EncodedUserData), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.V1alpha1UpdateServerPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "key": types.StringValue("value"), + }), + }, + &iaasalpha.V1alpha1UpdateServerPayload{ + Name: utils.Ptr("name"), + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 9b009e0d..7f8f8ff8 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -34,6 +34,8 @@ var ( ProjectId = os.Getenv("TF_ACC_PROJECT_ID") // ServerId is the id of a server used for some tests ServerId = getenv("TF_ACC_SERVER_ID", "") + // IaaSImageId is the id of an image used for IaaS acceptance tests. Once the stackit_image resource is implemented, we can remove this + IaaSImageId = getenv("TF_ACC_IMAGE_ID", "") // TestProjectParentContainerID is the container id of the parent resource under which projects are created as part of the resource-manager acceptance tests TestProjectParentContainerID = os.Getenv("TF_ACC_TEST_PROJECT_PARENT_CONTAINER_ID") // TestProjectParentContainerID is the uuid of the parent resource under which projects are created as part of the resource-manager acceptance tests diff --git a/stackit/provider.go b/stackit/provider.go index 147454dd..b503eed4 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -18,6 +18,7 @@ import ( iaasNetworkArea "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarea" iaasNetworkAreaRoute "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarearoute" iaasSecurityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygroup" + iaasServer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/server" iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume" loadBalancerCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/credential" loadBalancer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/loadbalancer/loadbalancer" @@ -403,6 +404,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasNetworkArea.NewNetworkAreaDataSource, iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource, iaasVolume.NewVolumeDataSource, + iaasServer.NewServerDataSource, iaasSecurityGroup.NewSecurityGroupDataSource, loadBalancer.NewLoadBalancerDataSource, logMeInstance.NewInstanceDataSource, @@ -449,6 +451,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasNetworkArea.NewNetworkAreaResource, iaasNetworkAreaRoute.NewNetworkAreaRouteResource, iaasVolume.NewVolumeResource, + iaasServer.NewServerResource, iaasSecurityGroup.NewSecurityGroupResource, loadBalancer.NewLoadBalancerResource, loadBalancerCredential.NewCredentialResource, From 56938480d36c1185a6ce911b0338044e18598d69 Mon Sep 17 00:00:00 2001 From: GokceGK <161626272+GokceGK@users.noreply.github.com> Date: Tue, 8 Oct 2024 10:52:49 +0200 Subject: [PATCH 5/6] Onboard IaaS network interface (#544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * implement network interface * handle labels * add CIDR validation * fix linter issues and generate docs * remove computed from the allowed addresses and fix the conditions * Update stackit/internal/services/iaas/networkinterface/resource.go Co-authored-by: Vicente Pinto * Update stackit/internal/services/iaas/networkinterface/datasource.go Co-authored-by: Vicente Pinto * apply code review changes * remove status from schema * remove unnecessary GET call * Update stackit/internal/services/iaas/networkinterface/resource.go Co-authored-by: Vicente Pinto * Update stackit/internal/services/iaas/networkinterface/resource.go Co-authored-by: Vicente Pinto * rename nic_security to security * add beta markdown description * use existing validateIP function * use utils function for the options listing * refactor labels * change function from utils to conversion * make allowed addresses a list of strings * add acceptance test for network interfaces * fix acceptance test * rename security_groups as security_group_ids * extend descriptions * fix acc test --------- Co-authored-by: Gökçe Gök Klingel Co-authored-by: Vicente Pinto --- docs/data-sources/network_interface.md | 47 ++ docs/resources/network_interface.md | 51 ++ .../stackit_network_interface/data-source.tf | 5 + .../stackit_network_interface/resource.tf | 6 + .../internal/services/iaas/iaas_acc_test.go | 90 ++- .../iaas/networkinterface/datasource.go | 210 ++++++ .../iaas/networkinterface/resource.go | 637 ++++++++++++++++++ .../iaas/networkinterface/resource_test.go | 275 ++++++++ stackit/provider.go | 3 + 9 files changed, 1321 insertions(+), 3 deletions(-) create mode 100644 docs/data-sources/network_interface.md create mode 100644 docs/resources/network_interface.md create mode 100644 examples/data-sources/stackit_network_interface/data-source.tf create mode 100644 examples/resources/stackit_network_interface/resource.tf create mode 100644 stackit/internal/services/iaas/networkinterface/datasource.go create mode 100644 stackit/internal/services/iaas/networkinterface/resource.go create mode 100644 stackit/internal/services/iaas/networkinterface/resource_test.go diff --git a/docs/data-sources/network_interface.md b/docs/data-sources/network_interface.md new file mode 100644 index 00000000..1f668206 --- /dev/null +++ b/docs/data-sources/network_interface.md @@ -0,0 +1,47 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_network_interface Data Source - stackit" +subcategory: "" +description: |- + Network interface datasource schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_network_interface (Data Source) + +Network interface datasource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +data "stackit_network_interface" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `network_id` (String) The network ID to which the network interface is associated. +- `network_interface_id` (String) The network interface ID. +- `project_id` (String) STACKIT project ID to which the network interface is associated. + +### Read-Only + +- `allowed_addresses` (List of String) The list of CIDR (Classless Inter-Domain Routing) notations. +- `device` (String) The device UUID of the network interface. +- `id` (String) Terraform's internal data source ID. It is structured as "`project_id`,`network_id`,`network_interface_id`". +- `ipv4` (String) The IPv4 address. +- `ipv6` (String) The IPv6 address. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a network interface. +- `mac` (String) The MAC address of network interface. +- `name` (String) The name of the network interface. +- `security` (Boolean) The Network Interface Security. If set to false, then no security groups will apply to this network interface. +- `security_group_ids` (List of String) The list of security group UUIDs. If security is set to false, setting this field will lead to an error. +- `type` (String) Type of network interface. Some of the possible values are: Supported values are: `server`, `metadata`, `gateway`. diff --git a/docs/resources/network_interface.md b/docs/resources/network_interface.md new file mode 100644 index 00000000..7d9700d2 --- /dev/null +++ b/docs/resources/network_interface.md @@ -0,0 +1,51 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_network_interface Resource - stackit" +subcategory: "" +description: |- + Network interface resource schema. Must have a region specified in the provider configuration. + ~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our guide https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources for how to opt-in to use beta resources. +--- + +# stackit_network_interface (Resource) + +Network interface resource schema. Must have a `region` specified in the provider configuration. + +~> This resource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +resource "stackit_network_interface" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + allowed_addresses = ["1.2.3.4"] + security_groups = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] +} +``` + + +## Schema + +### Required + +- `network_id` (String) The network ID to which the network interface is associated. +- `project_id` (String) STACKIT project ID to which the network is associated. + +### Optional + +- `allowed_addresses` (List of String) The list of CIDR (Classless Inter-Domain Routing) notations. +- `ipv4` (String) The IPv4 address. +- `ipv6` (String) The IPv6 address. +- `labels` (Map of String) Labels are key-value string pairs which can be attached to a network interface. +- `name` (String) The name of the network interface. +- `security` (Boolean) The Network Interface Security. If set to false, then no security groups will apply to this network interface. +- `security_group_ids` (List of String) The list of security group UUIDs. If security is set to false, setting this field will lead to an error. + +### Read-Only + +- `device` (String) The device UUID of the network interface. +- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`network_id`,`network_interface_id`". +- `mac` (String) The MAC address of network interface. +- `network_interface_id` (String) The network interface ID. +- `type` (String) Type of network interface. Some of the possible values are: Supported values are: `server`, `metadata`, `gateway`. diff --git a/examples/data-sources/stackit_network_interface/data-source.tf b/examples/data-sources/stackit_network_interface/data-source.tf new file mode 100644 index 00000000..2c223f40 --- /dev/null +++ b/examples/data-sources/stackit_network_interface/data-source.tf @@ -0,0 +1,5 @@ +data "stackit_network_interface" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_interface_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} \ No newline at end of file diff --git a/examples/resources/stackit_network_interface/resource.tf b/examples/resources/stackit_network_interface/resource.tf new file mode 100644 index 00000000..b3ee6f6c --- /dev/null +++ b/examples/resources/stackit_network_interface/resource.tf @@ -0,0 +1,6 @@ +resource "stackit_network_interface" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + network_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + allowed_addresses = ["1.2.3.4"] + security_groups = ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"] +} \ No newline at end of file diff --git a/stackit/internal/services/iaas/iaas_acc_test.go b/stackit/internal/services/iaas/iaas_acc_test.go index 9fddcb75..1c336ab1 100644 --- a/stackit/internal/services/iaas/iaas_acc_test.go +++ b/stackit/internal/services/iaas/iaas_acc_test.go @@ -45,6 +45,12 @@ var networkAreaRouteResource = map[string]string{ "next_hop": "1.1.1.1", } +var networkInterfaceResource = map[string]string{ + "project_id": testutil.ProjectId, + "network_id": networkResource["network_id"], + "name": "name", +} + // Volume resource data var volumeResource = map[string]string{ "project_id": testutil.ProjectId, @@ -125,6 +131,18 @@ func networkAreaRouteResourceConfig() string { ) } +func networkInterfaceResourceConfig(name string) string { + return fmt.Sprintf(` + resource "stackit_network_interface" "network_interface" { + project_id = stackit_network.network.project_id + network_id = stackit_network.network.network_id + name = "%s" + } + `, + name, + ) +} + func volumeResourceConfig(name, size string) string { return fmt.Sprintf(` resource "stackit_volume" "volume" { @@ -215,11 +233,12 @@ func testAccVolumeConfig(name, size string) string { ) } -func testAccServerConfig(name, nameservers, serverName, machineType string) string { - return fmt.Sprintf("%s\n\n%s\n\n%s", +func testAccServerConfig(name, nameservers, serverName, machineType, interfacename string) string { + return fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s", testutil.IaaSProviderConfig(), networkResourceConfig(name, nameservers), serverResourceConfig(serverName, machineType), + networkInterfaceResourceConfig(interfacename), ) } @@ -274,7 +293,7 @@ func TestAccNetworkArea(t *testing.T) { organization_id = stackit_network_area.network_area.organization_id network_area_id = stackit_network_area.network_area.network_area_id } - + data "stackit_network_area_route" "network_area_route" { organization_id = stackit_network_area.network_area.organization_id network_area_id = stackit_network_area.network_area.network_area_id @@ -472,6 +491,7 @@ func TestAccServer(t *testing.T) { ), serverResource["name"], serverResource["machine_type"], + networkInterfaceResource["name"], ), Check: resource.ComposeAggregateTestCheckFunc( @@ -490,6 +510,18 @@ func TestAccServer(t *testing.T) { resource.TestCheckResourceAttr("stackit_server.server", "machine_type", serverResource["machine_type"]), resource.TestCheckResourceAttr("stackit_server.server", "labels.label1", serverResource["label1"]), resource.TestCheckResourceAttr("stackit_server.server", "user_data", serverResource["user_data"]), + + // Network Interface + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "project_id", + "stackit_network.network", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "network_id", + "stackit_network.network", "network_id", + ), + resource.TestCheckResourceAttrSet("stackit_network_interface.network_interface", "network_interface_id"), + resource.TestCheckResourceAttr("stackit_network_interface.network_interface", "name", networkInterfaceResource["name"]), ), }, // Data source @@ -506,6 +538,12 @@ func TestAccServer(t *testing.T) { project_id = stackit_server.server.project_id server_id = stackit_server.server.server_id } + + data "stackit_network_interface" "network_interface" { + project_id = stackit_network.network.project_id + network_id = stackit_network.network.network_id + network_interface_id = stackit_network_interface.network_interface.network_interface_id + } `, testAccServerConfig( networkResource["name"], @@ -515,6 +553,7 @@ func TestAccServer(t *testing.T) { ), serverResource["name"], serverResource["machine_type"], + networkInterfaceResource["name"], ), ), Check: resource.ComposeAggregateTestCheckFunc( @@ -537,6 +576,18 @@ func TestAccServer(t *testing.T) { resource.TestCheckResourceAttr("data.stackit_server.server", "availability_zone", serverResource["availability_zone"]), resource.TestCheckResourceAttr("data.stackit_server.server", "machine_type", serverResource["machine_type"]), resource.TestCheckResourceAttr("data.stackit_server.server", "labels.label1", serverResource["label1"]), + + // Network Interface + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "project_id", + "stackit_network.network", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "network_id", + "stackit_network.network", "network_id", + ), + resource.TestCheckResourceAttrSet("stackit_network_interface.network_interface", "network_interface_id"), + resource.TestCheckResourceAttr("stackit_network_interface.network_interface", "name", networkInterfaceResource["name"]), ), }, // Import @@ -574,6 +625,26 @@ func TestAccServer(t *testing.T) { ImportStateVerify: true, ImportStateVerifyIgnore: []string{"initial_networking", "boot_volume", "user_data"}, // Field is not mapped as it is only relevant on creation }, + { + ResourceName: "stackit_network_interface.network_interface", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_network_interface.network_interface"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_network_interface.network_interface") + } + networkId, ok := r.Primary.Attributes["network_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_id") + } + networkInterfaceId, ok := r.Primary.Attributes["network_interface_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute network_interface_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, networkId, networkInterfaceId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, // Update { Config: testAccServerConfig( @@ -585,6 +656,7 @@ func TestAccServer(t *testing.T) { ), fmt.Sprintf("%s-updated", serverResource["name"]), updatedServerMachineType, + fmt.Sprintf("%s-updated", networkInterfaceResource["name"]), ), Check: resource.ComposeAggregateTestCheckFunc( // Network @@ -603,6 +675,18 @@ func TestAccServer(t *testing.T) { resource.TestCheckResourceAttr("stackit_server.server", "machine_type", updatedServerMachineType), resource.TestCheckResourceAttr("stackit_server.server", "labels.label1", serverResource["label1"]), resource.TestCheckResourceAttr("stackit_server.server", "user_data", serverResource["user_data"]), + + // Network interface + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "project_id", + "stackit_network.network", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_network_interface.network_interface", "network_id", + "stackit_network.network", "network_id", + ), + resource.TestCheckResourceAttrSet("stackit_network_interface.network_interface", "network_interface_id"), + resource.TestCheckResourceAttr("stackit_network_interface.network_interface", "name", fmt.Sprintf("%s-updated", networkInterfaceResource["name"])), ), }, // Deletion is done by the framework implicitly diff --git a/stackit/internal/services/iaas/networkinterface/datasource.go b/stackit/internal/services/iaas/networkinterface/datasource.go new file mode 100644 index 00000000..f190857a --- /dev/null +++ b/stackit/internal/services/iaas/networkinterface/datasource.go @@ -0,0 +1,210 @@ +package networkinterface + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// scheduleDataSourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var networkInterfaceDataSourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &networkInterfaceDataSource{} +) + +// NewNetworkDataSource is a helper function to simplify the provider implementation. +func NewNetworkInterfaceDataSource() datasource.DataSource { + return &networkInterfaceDataSource{} +} + +// networkInterfaceDataSource is the data source implementation. +type networkInterfaceDataSource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the data source type name. +func (d *networkInterfaceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_network_interface" +} + +func (d *networkInterfaceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + var apiClient *iaasalpha.APIClient + var err error + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !networkInterfaceDataSourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_network_interface", "data source") + if resp.Diagnostics.HasError() { + return + } + networkInterfaceDataSourceBetaCheckDone = true + } + + if providerData.IaaSCustomEndpoint != "" { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the data source configuration", err)) + return + } + + d.client = apiClient + tflog.Info(ctx, "IaaS client configured") +} + +// Schema defines the schema for the data source. +func (d *networkInterfaceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + typeOptions := []string{"server", "metadata", "gateway"} + + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Network interface datasource schema. Must have a `region` specified in the provider configuration."), + Description: "Network interface datasource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal data source ID. It is structured as \"`project_id`,`network_id`,`network_interface_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the network interface is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_id": schema.StringAttribute{ + Description: "The network ID to which the network interface is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_interface_id": schema.StringAttribute{ + Description: "The network interface ID.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the network interface.", + Computed: true, + }, + "allowed_addresses": schema.ListAttribute{ + Description: "The list of CIDR (Classless Inter-Domain Routing) notations.", + Computed: true, + ElementType: types.StringType, + }, + "device": schema.StringAttribute{ + Description: "The device UUID of the network interface.", + Computed: true, + }, + "ipv4": schema.StringAttribute{ + Description: "The IPv4 address.", + Computed: true, + }, + "ipv6": schema.StringAttribute{ + Description: "The IPv6 address.", + Computed: true, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a network interface.", + ElementType: types.StringType, + Computed: true, + }, + "mac": schema.StringAttribute{ + Description: "The MAC address of network interface.", + Computed: true, + }, + "security": schema.BoolAttribute{ + Description: "The Network Interface Security. If set to false, then no security groups will apply to this network interface.", + Computed: true, + }, + "security_group_ids": schema.ListAttribute{ + Description: "The list of security group UUIDs. If security is set to false, setting this field will lead to an error.", + Computed: true, + ElementType: types.StringType, + }, + "type": schema.StringAttribute{ + Description: "Type of network interface. Some of the possible values are: " + utils.SupportedValuesDocumentation(typeOptions), + Computed: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *networkInterfaceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Config.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + networkInterfaceResp, err := d.client.GetNIC(ctx, projectId, networkId, networkInterfaceId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, networkInterfaceResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network interface read") +} diff --git a/stackit/internal/services/iaas/networkinterface/resource.go b/stackit/internal/services/iaas/networkinterface/resource.go new file mode 100644 index 00000000..bc0de6d3 --- /dev/null +++ b/stackit/internal/services/iaas/networkinterface/resource.go @@ -0,0 +1,637 @@ +package networkinterface + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// resourceBetaCheckDone is used to prevent multiple checks for beta resources. +// This is a workaround for the lack of a global state in the provider and +// needs to exist because the Configure method is called twice. +var resourceBetaCheckDone bool + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &networkInterfaceResource{} + _ resource.ResourceWithConfigure = &networkInterfaceResource{} + _ resource.ResourceWithImportState = &networkInterfaceResource{} +) + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + NetworkId types.String `tfsdk:"network_id"` + NetworkInterfaceId types.String `tfsdk:"network_interface_id"` + Name types.String `tfsdk:"name"` + AllowedAddresses types.List `tfsdk:"allowed_addresses"` + IPv4 types.String `tfsdk:"ipv4"` + IPv6 types.String `tfsdk:"ipv6"` + Labels types.Map `tfsdk:"labels"` + Security types.Bool `tfsdk:"security"` + SecurityGroupIds types.List `tfsdk:"security_group_ids"` + Device types.String `tfsdk:"device"` + Mac types.String `tfsdk:"mac"` + Type types.String `tfsdk:"type"` +} + +// NewNetworkInterfaceResource is a helper function to simplify the provider implementation. +func NewNetworkInterfaceResource() resource.Resource { + return &networkInterfaceResource{} +} + +// networkResource is the resource implementation. +type networkInterfaceResource struct { + client *iaasalpha.APIClient +} + +// Metadata returns the resource type name. +func (r *networkInterfaceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_network_interface" +} + +// Configure adds the provider configured client to the resource. +func (r *networkInterfaceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + if !resourceBetaCheckDone { + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_network_interface", "resource") + if resp.Diagnostics.HasError() { + return + } + resourceBetaCheckDone = true + } + + var apiClient *iaasalpha.APIClient + var err error + if providerData.IaaSCustomEndpoint != "" { + ctx = tflog.SetField(ctx, "iaas_custom_endpoint", providerData.IaaSCustomEndpoint) + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.IaaSCustomEndpoint), + ) + } else { + apiClient, err = iaasalpha.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithRegion(providerData.Region), + ) + } + + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err)) + return + } + + r.client = apiClient + tflog.Info(ctx, "IaaSalpha client configured") +} + +// Schema defines the schema for the resource. +func (r *networkInterfaceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + typeOptions := []string{"server", "metadata", "gateway"} + + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Network interface resource schema. Must have a `region` specified in the provider configuration."), + Description: "Network interface resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal resource ID. It is structured as \"`project_id`,`network_id`,`network_interface_id`\".", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the network is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_id": schema.StringAttribute{ + Description: "The network ID to which the network interface is associated.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "network_interface_id": schema.StringAttribute{ + Description: "The network interface ID.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the network interface.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + stringvalidator.LengthAtMost(63), + stringvalidator.RegexMatches( + regexp.MustCompile(`^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`), + "must match expression"), + }, + }, + "allowed_addresses": schema.ListAttribute{ + Description: "The list of CIDR (Classless Inter-Domain Routing) notations.", + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + "device": schema.StringAttribute{ + Description: "The device UUID of the network interface.", + Computed: true, + }, + "ipv4": schema.StringAttribute{ + Description: "The IPv4 address.", + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.IP(), + }, + }, + "ipv6": schema.StringAttribute{ + Description: "The IPv6 address.", + Optional: true, + Computed: true, + Validators: []validator.String{ + validate.IP(), + }, + }, + "labels": schema.MapAttribute{ + Description: "Labels are key-value string pairs which can be attached to a network interface.", + ElementType: types.StringType, + Optional: true, + }, + "mac": schema.StringAttribute{ + Description: "The MAC address of network interface.", + Computed: true, + }, + "security": schema.BoolAttribute{ + Description: "The Network Interface Security. If set to false, then no security groups will apply to this network interface.", + Computed: true, + Optional: true, + }, + "security_group_ids": schema.ListAttribute{ + Description: "The list of security group UUIDs. If security is set to false, setting this field will lead to an error.", + Optional: true, + Computed: true, + ElementType: types.StringType, + Validators: []validator.List{ + listvalidator.ValueStringsAre( + stringvalidator.RegexMatches( + regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`), + "must match expression"), + ), + }, + }, + "type": schema.StringAttribute{ + Description: "Type of network interface. Some of the possible values are: " + utils.SupportedValuesDocumentation(typeOptions), + Computed: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *networkInterfaceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + networkId := model.NetworkId.ValueString() + ctx = tflog.SetField(ctx, "network_id", networkId) + + // Generate API request body from model + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new network interface + networkInterface, err := r.client.CreateNIC(ctx, projectId, networkId).CreateNICPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + networkInterfaceId := *networkInterface.Id + + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + // Map response body to schema + err = mapFields(ctx, networkInterface, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating network interface", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network interface created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *networkInterfaceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + networkInterfaceResp, err := r.client.GetNIC(ctx, projectId, networkId, networkInterfaceId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + // Map response body to schema + err = mapFields(ctx, networkInterfaceResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading network interface", fmt.Sprintf("Processing API payload: %v", err)) + return + } + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network interface read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *networkInterfaceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + // Retrieve values from state + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(ctx, &model, stateModel.Labels) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Creating API payload: %v", err)) + return + } + // Update existing network + nicResp, err := r.client.UpdateNIC(ctx, projectId, networkId, networkInterfaceId).UpdateNICPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + err = mapFields(ctx, nicResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating network interface", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Network interface updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *networkInterfaceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from state + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + networkId := model.NetworkId.ValueString() + networkInterfaceId := model.NetworkInterfaceId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + // Delete existing network interface + err := r.client.DeleteNIC(ctx, projectId, networkId, networkInterfaceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting network interface", fmt.Sprintf("Calling API: %v", err)) + return + } + + tflog.Info(ctx, "Network interface deleted") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,network_id,network_interface_id +func (r *networkInterfaceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing network interface", + fmt.Sprintf("Expected import identifier with format: [project_id],[network_id],[network_interface_id] Got: %q", req.ID), + ) + return + } + + projectId := idParts[0] + networkId := idParts[1] + networkInterfaceId := idParts[2] + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "network_id", networkId) + ctx = tflog.SetField(ctx, "network_interface_id", networkInterfaceId) + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_id"), networkId)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("network_interface_id"), networkInterfaceId)...) + tflog.Info(ctx, "Network interface state imported") +} + +func mapFields(ctx context.Context, networkInterfaceResp *iaasalpha.NIC, model *Model) error { + if networkInterfaceResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + var networkInterfaceId string + if model.NetworkInterfaceId.ValueString() != "" { + networkInterfaceId = model.NetworkInterfaceId.ValueString() + } else if networkInterfaceResp.NetworkId != nil { + networkInterfaceId = *networkInterfaceResp.Id + } else { + return fmt.Errorf("network interface id not present") + } + + idParts := []string{ + model.ProjectId.ValueString(), + model.NetworkId.ValueString(), + networkInterfaceId, + } + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + + respAllowedAddresses := []string{} + var diags diag.Diagnostics + if networkInterfaceResp.AllowedAddresses == nil { + model.AllowedAddresses = types.ListNull(types.StringType) + } else { + for _, n := range *networkInterfaceResp.AllowedAddresses { + respAllowedAddresses = append(respAllowedAddresses, *n.String) + } + + modelAllowedAddresses, err := utils.ListValuetoStringSlice(model.AllowedAddresses) + if err != nil { + return fmt.Errorf("get current network interface allowed addresses from model: %w", err) + } + + reconciledAllowedAddresses := utils.ReconcileStringSlices(modelAllowedAddresses, respAllowedAddresses) + + allowedAddressesTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledAllowedAddresses) + if diags.HasError() { + return fmt.Errorf("map network interface allowed addresses: %w", core.DiagsToError(diags)) + } + + model.AllowedAddresses = allowedAddressesTF + } + + if networkInterfaceResp.SecurityGroups == nil { + model.SecurityGroupIds = types.ListNull(types.StringType) + } else { + respSecurityGroups := *networkInterfaceResp.SecurityGroups + modelSecurityGroups, err := utils.ListValuetoStringSlice(model.SecurityGroupIds) + if err != nil { + return fmt.Errorf("get current network interface security groups from model: %w", err) + } + + reconciledSecurityGroups := utils.ReconcileStringSlices(modelSecurityGroups, respSecurityGroups) + + securityGroupsTF, diags := types.ListValueFrom(ctx, types.StringType, reconciledSecurityGroups) + if diags.HasError() { + return fmt.Errorf("map network interface security groups: %w", core.DiagsToError(diags)) + } + + model.SecurityGroupIds = securityGroupsTF + } + + labels, diags := types.MapValueFrom(ctx, types.StringType, map[string]interface{}{}) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + if networkInterfaceResp.Labels != nil && len(*networkInterfaceResp.Labels) != 0 { + var diags diag.Diagnostics + labels, diags = types.MapValueFrom(ctx, types.StringType, *networkInterfaceResp.Labels) + if diags.HasError() { + return fmt.Errorf("converting labels to StringValue map: %w", core.DiagsToError(diags)) + } + } else if model.Labels.IsNull() { + labels = types.MapNull(types.StringType) + } + + model.NetworkInterfaceId = types.StringValue(networkInterfaceId) + model.Name = types.StringPointerValue(networkInterfaceResp.Name) + model.IPv4 = types.StringPointerValue(networkInterfaceResp.Ipv4) + model.IPv6 = types.StringPointerValue(networkInterfaceResp.Ipv6) + model.Security = types.BoolPointerValue(networkInterfaceResp.NicSecurity) + model.Device = types.StringPointerValue(networkInterfaceResp.Device) + model.Mac = types.StringPointerValue(networkInterfaceResp.Mac) + model.Type = types.StringPointerValue(networkInterfaceResp.Type) + model.Labels = labels + + return nil +} + +func toCreatePayload(ctx context.Context, model *Model) (*iaasalpha.CreateNICPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + var labelPayload *map[string]interface{} + + modelSecurityGroups := []string{} + if !(model.SecurityGroupIds.IsNull() || model.SecurityGroupIds.IsUnknown()) { + for _, ns := range model.SecurityGroupIds.Elements() { + securityGroupString, ok := ns.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + modelSecurityGroups = append(modelSecurityGroups, securityGroupString.ValueString()) + } + } + + allowedAddressesPayload := []iaasalpha.AllowedAddressesInner{} + + if !(model.AllowedAddresses.IsNull() || model.AllowedAddresses.IsUnknown()) { + for _, allowedAddressModel := range model.AllowedAddresses.Elements() { + allowedAddressString, ok := allowedAddressModel.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + + allowedAddressesPayload = append(allowedAddressesPayload, iaasalpha.AllowedAddressesInner{ + String: conversion.StringValueToPointer(allowedAddressString), + }) + } + } + + if !model.Labels.IsNull() && !model.Labels.IsUnknown() { + labelMap, err := conversion.ToStringInterfaceMap(ctx, model.Labels) + if err != nil { + return nil, fmt.Errorf("mapping labels: %w", err) + } + labelPayload = &labelMap + } + + return &iaasalpha.CreateNICPayload{ + AllowedAddresses: &allowedAddressesPayload, + SecurityGroups: &modelSecurityGroups, + Labels: labelPayload, + Name: conversion.StringValueToPointer(model.Name), + Device: conversion.StringValueToPointer(model.Device), + Ipv4: conversion.StringValueToPointer(model.IPv4), + Ipv6: conversion.StringValueToPointer(model.IPv6), + Mac: conversion.StringValueToPointer(model.Mac), + Type: conversion.StringValueToPointer(model.Type), + }, nil +} + +func toUpdatePayload(ctx context.Context, model *Model, currentLabels types.Map) (*iaasalpha.UpdateNICPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + var labelPayload *map[string]interface{} + + modelSecurityGroups := []string{} + for _, ns := range model.SecurityGroupIds.Elements() { + securityGroupString, ok := ns.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + modelSecurityGroups = append(modelSecurityGroups, securityGroupString.ValueString()) + } + + allowedAddressesPayload := []iaasalpha.AllowedAddressesInner{} + + if !(model.AllowedAddresses.IsNull() || model.AllowedAddresses.IsUnknown()) { + for _, allowedAddressModel := range model.AllowedAddresses.Elements() { + allowedAddressString, ok := allowedAddressModel.(types.String) + if !ok { + return nil, fmt.Errorf("type assertion failed") + } + + allowedAddressesPayload = append(allowedAddressesPayload, iaasalpha.AllowedAddressesInner{ + String: conversion.StringValueToPointer(allowedAddressString), + }) + } + } + + if !model.Labels.IsNull() && !model.Labels.IsUnknown() { + labelMap, err := conversion.ToJSONMapPartialUpdatePayload(ctx, currentLabels, model.Labels) + if err != nil { + return nil, fmt.Errorf("mapping labels: %w", err) + } + labelPayload = &labelMap + } + + return &iaasalpha.UpdateNICPayload{ + AllowedAddresses: &allowedAddressesPayload, + SecurityGroups: &modelSecurityGroups, + Labels: labelPayload, + Name: conversion.StringValueToPointer(model.Name), + Device: conversion.StringValueToPointer(model.Device), + Ipv4: conversion.StringValueToPointer(model.IPv4), + Ipv6: conversion.StringValueToPointer(model.IPv6), + Mac: conversion.StringValueToPointer(model.Mac), + Type: conversion.StringValueToPointer(model.Type), + }, nil +} diff --git a/stackit/internal/services/iaas/networkinterface/resource_test.go b/stackit/internal/services/iaas/networkinterface/resource_test.go new file mode 100644 index 00000000..4b785564 --- /dev/null +++ b/stackit/internal/services/iaas/networkinterface/resource_test.go @@ -0,0 +1,275 @@ +package networkinterface + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaasalpha" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + state Model + input *iaasalpha.NIC + expected Model + isValid bool + }{ + { + "id_ok", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + &iaasalpha.NIC{ + Id: utils.Ptr("nicid"), + }, + Model{ + Id: types.StringValue("pid,nid,nicid"), + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + Name: types.StringNull(), + AllowedAddresses: types.ListNull(types.StringType), + SecurityGroupIds: types.ListNull(types.StringType), + IPv4: types.StringNull(), + IPv6: types.StringNull(), + Security: types.BoolNull(), + Device: types.StringNull(), + Mac: types.StringNull(), + Type: types.StringNull(), + Labels: types.MapNull(types.StringType), + }, + true, + }, + { + "values_ok", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + }, + &iaasalpha.NIC{ + Id: utils.Ptr("nicid"), + Name: utils.Ptr("name"), + AllowedAddresses: &[]iaasalpha.AllowedAddressesInner{ + { + String: utils.Ptr("aa1"), + }, + }, + SecurityGroups: &[]string{ + "prefix1", + "prefix2", + }, + Ipv4: utils.Ptr("ipv4"), + Ipv6: utils.Ptr("ipv6"), + NicSecurity: utils.Ptr(true), + Device: utils.Ptr("device"), + Mac: utils.Ptr("mac"), + Status: utils.Ptr("status"), + Type: utils.Ptr("type"), + Labels: &map[string]interface{}{ + "label1": "ref1", + }, + }, + Model{ + Id: types.StringValue("pid,nid,nicid"), + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + Name: types.StringValue("name"), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa1"), + }), + SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("prefix1"), + types.StringValue("prefix2"), + }), + IPv4: types.StringValue("ipv4"), + IPv6: types.StringValue("ipv6"), + Security: types.BoolValue(true), + Device: types.StringValue("device"), + Mac: types.StringValue("mac"), + Type: types.StringValue("type"), + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"label1": types.StringValue("ref1")}), + }, + true, + }, + { + "allowed_addresses_changed_outside_tf", + Model{ + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa1"), + }), + }, + &iaasalpha.NIC{ + Id: utils.Ptr("nicid"), + AllowedAddresses: &[]iaasalpha.AllowedAddressesInner{ + { + String: utils.Ptr("aa2"), + }, + }, + }, + Model{ + Id: types.StringValue("pid,nid,nicid"), + ProjectId: types.StringValue("pid"), + NetworkId: types.StringValue("nid"), + NetworkInterfaceId: types.StringValue("nicid"), + Name: types.StringNull(), + SecurityGroupIds: types.ListNull(types.StringType), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa2"), + }), + Labels: types.MapNull(types.StringType), + }, + true, + }, + { + "response_nil_fail", + Model{}, + nil, + Model{}, + false, + }, + { + "no_resource_id", + Model{ + ProjectId: types.StringValue("pid"), + }, + &iaasalpha.NIC{}, + Model{}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := mapFields(context.Background(), tt.input, &tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(tt.state, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.CreateNICPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("sg1"), + types.StringValue("sg2"), + }), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa1"), + }), + }, + &iaasalpha.CreateNICPayload{ + Name: utils.Ptr("name"), + SecurityGroups: &[]string{ + "sg1", + "sg2", + }, + AllowedAddresses: &[]iaasalpha.AllowedAddressesInner{ + { + String: utils.Ptr("aa1"), + }, + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(context.Background(), tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *iaasalpha.UpdateNICPayload + isValid bool + }{ + { + "default_ok", + &Model{ + Name: types.StringValue("name"), + SecurityGroupIds: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("sg1"), + types.StringValue("sg2"), + }), + AllowedAddresses: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("aa1"), + }), + }, + &iaasalpha.UpdateNICPayload{ + Name: utils.Ptr("name"), + SecurityGroups: &[]string{ + "sg1", + "sg2", + }, + AllowedAddresses: &[]iaasalpha.AllowedAddressesInner{ + { + String: utils.Ptr("aa1"), + }, + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(context.Background(), tt.input, types.MapNull(types.StringType)) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/provider.go b/stackit/provider.go index b503eed4..56c2ccb5 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -17,6 +17,7 @@ import ( iaasNetwork "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/network" iaasNetworkArea "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarea" iaasNetworkAreaRoute "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkarearoute" + iaasNetworkInterface "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/networkinterface" iaasSecurityGroup "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/securitygroup" iaasServer "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/server" iaasVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/iaas/volume" @@ -403,6 +404,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource iaasNetwork.NewNetworkDataSource, iaasNetworkArea.NewNetworkAreaDataSource, iaasNetworkAreaRoute.NewNetworkAreaRouteDataSource, + iaasNetworkInterface.NewNetworkInterfaceDataSource, iaasVolume.NewVolumeDataSource, iaasServer.NewServerDataSource, iaasSecurityGroup.NewSecurityGroupDataSource, @@ -450,6 +452,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { iaasNetwork.NewNetworkResource, iaasNetworkArea.NewNetworkAreaResource, iaasNetworkAreaRoute.NewNetworkAreaRouteResource, + iaasNetworkInterface.NewNetworkInterfaceResource, iaasVolume.NewVolumeResource, iaasServer.NewServerResource, iaasSecurityGroup.NewSecurityGroupResource, From 1c208e7d623069921cc11ea8792bb0b70bbb9e37 Mon Sep 17 00:00:00 2001 From: Vicente Pinto Date: Tue, 8 Oct 2024 15:56:42 +0100 Subject: [PATCH 6/6] Draft int64 validation --- go.mod | 2 +- go.sum | 4 +- .../internal/services/iaas/volume/resource.go | 3 + stackit/internal/validate/int64validate.go | 87 +++++++++++++++++++ .../internal/validate/int64validate_test.txt | 61 +++++++++++++ .../{validate.go => stringvalidate.go} | 54 ++++++------ ...alidate_test.go => stringvalidate_test.go} | 0 7 files changed, 181 insertions(+), 30 deletions(-) create mode 100644 stackit/internal/validate/int64validate.go create mode 100644 stackit/internal/validate/int64validate_test.txt rename stackit/internal/validate/{validate.go => stringvalidate.go} (85%) rename stackit/internal/validate/{validate_test.go => stringvalidate_test.go} (100%) diff --git a/go.mod b/go.mod index e486fd9a..497bfb84 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.10.0 - github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.6-alpha + github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.7-alpha github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.19.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.19.0 diff --git a/go.sum b/go.sum index 036b063f..8906ebf9 100644 --- a/go.sum +++ b/go.sum @@ -157,8 +157,8 @@ github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 h1:QIZfs6nJ/l2pOweH1 github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0/go.mod h1:MdZcRbs19s2NLeJmSLSoqTzm9IPIQhE1ZEMpo9gePq0= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.10.0 h1:bqZ1CqiQLcu//4zc859XwYT/IZFjnlV9QeemmDbCPxc= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.10.0/go.mod h1:hEsLOmcqMFG0ftXSYOF8YtDrclkA0E89msGsH69B/BU= -github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.6-alpha h1:XUYncbRKaqbG76OzoSugfvPHp6+0A86JJxW2T3CLT2E= -github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.6-alpha/go.mod h1:b4KR6r+yWS2hsDkz6ebRqxgadB+ZsAZcG0oDfv5jeaY= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.7-alpha h1:jMEuK4M39S/WCF9p2k/qkoEhxqnL5gffJerK8FwhYRY= +github.com/stackitcloud/stackit-sdk-go/services/iaasalpha v0.1.7-alpha/go.mod h1:b4KR6r+yWS2hsDkz6ebRqxgadB+ZsAZcG0oDfv5jeaY= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0 h1:/GwkGMD7ID5hSjdZs1l/Mj8waceCt7oj3TxHgBfEMDQ= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.14.0/go.mod h1:wsO3+vXe1XiKLeCIctWAptaHQZ07Un7kmLTQ+drbj7w= github.com/stackitcloud/stackit-sdk-go/services/logme v0.19.0 h1:KcsF549yXOrm8zlqFCNV+lf2L4zvQuh4L2i3kgdWbOE= diff --git a/stackit/internal/services/iaas/volume/resource.go b/stackit/internal/services/iaas/volume/resource.go index 7f8d443c..2b14002e 100644 --- a/stackit/internal/services/iaas/volume/resource.go +++ b/stackit/internal/services/iaas/volume/resource.go @@ -247,6 +247,9 @@ func (r *volumeResource) Schema(_ context.Context, _ resource.SchemaRequest, res "size": schema.Int64Attribute{ Description: "The size of the volume in GB. It can only be updated to a larger value than the current size. Either `size` or `source` must be provided", Optional: true, + Validators: []validator.Int64{ + validate.OnlyUpdateToLargerValue(), + }, }, "source": schema.SingleNestedAttribute{ Description: "The source of the volume. It can be either a volume, an image, a snapshot or a backup. Either `size` or `source` must be provided", diff --git a/stackit/internal/validate/int64validate.go b/stackit/internal/validate/int64validate.go new file mode 100644 index 00000000..616a954b --- /dev/null +++ b/stackit/internal/validate/int64validate.go @@ -0,0 +1,87 @@ +package validate + +import ( + "context" + "fmt" + _ "time/tzdata" + + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +type Int64Validator struct { + description string + markdownDescription string + validate Int64ValidationFn +} + +type Int64ValidationFn func(context.Context, validator.Int64Request, *validator.Int64Response) + +var _ = validator.Int64(&Int64Validator{}) + +func (v *Int64Validator) Description(_ context.Context) string { + return v.description +} + +func (v *Int64Validator) MarkdownDescription(_ context.Context) string { + return v.markdownDescription +} + +func (v *Int64Validator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { // nolint:gocritic // function signature required by Terraform + if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() { + return + } + v.validate(ctx, req, resp) +} + +func OnlyUpdateToLargerValue() *Int64Validator { + description := "value can only be updated to a larger value than the current one (%d)" + + return &Int64Validator{ + description: description, + validate: func(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) { + attributePath := req.Path + var currentAttributeValue attr.Value + diags := req.Config.GetAttribute(ctx, attributePath, currentAttributeValue) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + "get current value of the attribute failed, path might be wrong", + string(req.ConfigValue.ValueInt64()), + )) + } + + // If the current path value is null or unknown, there is no validation to be done + if currentAttributeValue.IsNull() || currentAttributeValue.IsUnknown() { + return + } + + terraformValue, err := currentAttributeValue.ToTerraformValue(ctx) + if err != nil { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + fmt.Sprintf("converting current attribute value to terraform value: %w", err), + string(req.ConfigValue.ValueInt64()), + )) + } + var currentIntValue int64 + if err := terraformValue.Copy().As(¤tIntValue); err != nil { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + fmt.Sprintf("converting current attribute value to int64: %w", err), + string(req.ConfigValue.ValueInt64()), + )) + } + + if currentIntValue <= req.ConfigValue.ValueInt64() { + resp.Diagnostics.AddAttributeError( + req.Path, + fmt.Sprintf(description, currentIntValue), + string(req.ConfigValue.ValueInt64()), + ) + } + }, + } +} diff --git a/stackit/internal/validate/int64validate_test.txt b/stackit/internal/validate/int64validate_test.txt new file mode 100644 index 00000000..6d19a616 --- /dev/null +++ b/stackit/internal/validate/int64validate_test.txt @@ -0,0 +1,61 @@ +// package validate + +// import ( +// "context" +// "testing" + +// "github.com/hashicorp/terraform-plugin-framework/path" +// "github.com/hashicorp/terraform-plugin-framework/schema/validator" +// "github.com/hashicorp/terraform-plugin-framework/tfsdk" +// "github.com/hashicorp/terraform-plugin-framework/types" +// ) + +// func TestOnlyUpdateToLargerValue(t *testing.T) { +// tests := []struct { +// description string +// input int64 +// currentValue int64 +// isValid bool +// }{ +// // { +// // "ok", +// // "cae27bba-c43d-498a-861e-d11d241c4ff8", +// // true, +// // }, +// // { +// // "too short", +// // "a-b-c-d", +// // false, +// // }, +// // { +// // "Empty", +// // "", +// // false, +// // }, +// // { +// // "not UUID", +// // "www-541-%", +// // false, +// // }, +// } +// for _, tt := range tests { +// t.Run(tt.description, func(t *testing.T) { +// r := validator.Int64Response{} +// path := path.Root("test") +// OnlyUpdateToLargerValue().ValidateInt64(context.Background(), validator.Int64Request{ +// Path: path, +// ConfigValue: types.Int64Value(tt.input), +// Config: tfsdk.Config{ +// Schema: , +// }, +// }, &r) + +// if !tt.isValid && !r.Diagnostics.HasError() { +// t.Fatalf("Should have failed") +// } +// if tt.isValid && r.Diagnostics.HasError() { +// t.Fatalf("Should not have failed: %v", r.Diagnostics.Errors()) +// } +// }) +// } +// } diff --git a/stackit/internal/validate/validate.go b/stackit/internal/validate/stringvalidate.go similarity index 85% rename from stackit/internal/validate/validate.go rename to stackit/internal/validate/stringvalidate.go index 8b6ec78e..e9203ed7 100644 --- a/stackit/internal/validate/validate.go +++ b/stackit/internal/validate/stringvalidate.go @@ -24,35 +24,35 @@ const ( FullVersionRegex = `^\d+\.\d+.\d+?$` ) -type Validator struct { +type StringValidator struct { description string markdownDescription string - validate ValidationFn + validate StringValidationFn } -type ValidationFn func(context.Context, validator.StringRequest, *validator.StringResponse) +type StringValidationFn func(context.Context, validator.StringRequest, *validator.StringResponse) -var _ = validator.String(&Validator{}) +var _ = validator.String(&StringValidator{}) -func (v *Validator) Description(_ context.Context) string { +func (v *StringValidator) Description(_ context.Context) string { return v.description } -func (v *Validator) MarkdownDescription(_ context.Context) string { +func (v *StringValidator) MarkdownDescription(_ context.Context) string { return v.markdownDescription } -func (v *Validator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { // nolint:gocritic // function signature required by Terraform +func (v *StringValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { // nolint:gocritic // function signature required by Terraform if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() { return } v.validate(ctx, req, resp) } -func UUID() *Validator { +func UUID() *StringValidator { description := "value must be an UUID" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { if _, err := uuid.Parse(req.ConfigValue.ValueString()); err != nil { @@ -66,10 +66,10 @@ func UUID() *Validator { } } -func IP() *Validator { +func IP() *StringValidator { description := "value must be an IP address" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { if net.ParseIP(req.ConfigValue.ValueString()) == nil { @@ -83,9 +83,9 @@ func IP() *Validator { } } -func RecordSet() *Validator { +func RecordSet() *StringValidator { const typePath = "type" - return &Validator{ + return &StringValidator{ description: "value must be a valid record set", validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { recordType := basetypes.StringValue{} @@ -122,10 +122,10 @@ func RecordSet() *Validator { } } -func NoSeparator() *Validator { +func NoSeparator() *StringValidator { description := fmt.Sprintf("value must not contain identifier separator '%s'", core.Separator) - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { if strings.Contains(req.ConfigValue.ValueString(), core.Separator) { @@ -139,10 +139,10 @@ func NoSeparator() *Validator { } } -func NonLegacyProjectRole() *Validator { +func NonLegacyProjectRole() *StringValidator { description := "legacy roles are not supported" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { if utils.IsLegacyProjectRole(req.ConfigValue.ValueString()) { @@ -156,10 +156,10 @@ func NonLegacyProjectRole() *Validator { } } -func MinorVersionNumber() *Validator { +func MinorVersionNumber() *StringValidator { description := "value must be a minor version number, without a leading 'v': '[MAJOR].[MINOR]'" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { exp := MajorMinorVersionRegex @@ -176,10 +176,10 @@ func MinorVersionNumber() *Validator { } } -func VersionNumber() *Validator { +func VersionNumber() *StringValidator { description := "value must be a version number, without a leading 'v': '[MAJOR].[MINOR]' or '[MAJOR].[MINOR].[PATCH]'" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { minorVersionExp := MajorMinorVersionRegex @@ -200,10 +200,10 @@ func VersionNumber() *Validator { } } -func RFC3339SecondsOnly() *Validator { +func RFC3339SecondsOnly() *StringValidator { description := "value must be in RFC339 format (seconds only)" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { t, err := time.Parse(time.RFC3339, req.ConfigValue.ValueString()) @@ -228,10 +228,10 @@ func RFC3339SecondsOnly() *Validator { } } -func CIDR() *Validator { +func CIDR() *StringValidator { description := "value must be in CIDR notation" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { _, _, err := net.ParseCIDR(req.ConfigValue.ValueString()) @@ -246,10 +246,10 @@ func CIDR() *Validator { } } -func Rrule() *Validator { +func Rrule() *StringValidator { description := "value must be in a valid RRULE format" - return &Validator{ + return &StringValidator{ description: description, validate: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { // The go library rrule-go expects \n before RRULE (to be a newline and not a space) diff --git a/stackit/internal/validate/validate_test.go b/stackit/internal/validate/stringvalidate_test.go similarity index 100% rename from stackit/internal/validate/validate_test.go rename to stackit/internal/validate/stringvalidate_test.go