From 0131d00d2d069f2f5afe253333cad91ad3dfb67f Mon Sep 17 00:00:00 2001 From: Alexander Dahmen Date: Mon, 7 Sep 2026 08:47:36 +0200 Subject: [PATCH 1/5] feat(automation): Onboard volume backup automation STACKITTPR-550 Signed-off-by: Alexander Dahmen --- docs/data-sources/volume_automation.md | 86 ++ docs/resources/volume_automation.md | 126 +++ .../stackit_volume_automation/data-source.tf | 4 + .../import-by-string-id.tf | 5 + .../stackit_volume_automation/resource.tf | 24 + stackit/internal/conversion/conversion.go | 10 + .../internal/conversion/conversion_test.go | 31 + .../automation/automation_acc_test.go | 301 ++++++- .../automation/testdata/datasource.tf | 4 + .../testdata/resource-max-cleared.tf | 28 + .../automation/testdata/resource-max.tf | 32 + .../automation/testdata/resource-min.tf | 22 + .../services/automation/volume/datasource.go | 217 ++++++ .../services/automation/volume/resource.go | 733 ++++++++++++++++++ .../automation/volume/resource_test.go | 386 +++++++++ stackit/internal/testutil/testutil.go | 2 + stackit/provider.go | 3 + 17 files changed, 2012 insertions(+), 2 deletions(-) create mode 100644 docs/data-sources/volume_automation.md create mode 100644 docs/resources/volume_automation.md create mode 100644 examples/data-sources/stackit_volume_automation/data-source.tf create mode 100644 examples/resources/stackit_volume_automation/import-by-string-id.tf create mode 100644 examples/resources/stackit_volume_automation/resource.tf create mode 100644 stackit/internal/services/automation/testdata/datasource.tf create mode 100644 stackit/internal/services/automation/testdata/resource-max-cleared.tf create mode 100644 stackit/internal/services/automation/testdata/resource-max.tf create mode 100644 stackit/internal/services/automation/testdata/resource-min.tf create mode 100644 stackit/internal/services/automation/volume/datasource.go create mode 100644 stackit/internal/services/automation/volume/resource.go create mode 100644 stackit/internal/services/automation/volume/resource_test.go diff --git a/docs/data-sources/volume_automation.md b/docs/data-sources/volume_automation.md new file mode 100644 index 000000000..5ace58b76 --- /dev/null +++ b/docs/data-sources/volume_automation.md @@ -0,0 +1,86 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_volume_automation Data Source - stackit" +subcategory: "" +description: |- + Volume automation datasource schema. Must have a region specified in the provider configuration. + ~> This datasource 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_automation (Data Source) + +Volume automation datasource schema. Must have a `region` specified in the provider configuration. + +~> This datasource is in beta and may be subject to breaking changes in the future. Use with caution. See our [guide](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/guides/opting_into_beta_resources) for how to opt-in to use beta resources. + +## Example Usage + +```terraform +data "stackit_volume_automation" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + automation_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} +``` + + +## Schema + +### Required + +- `automation_id` (String) ID of the volume automation. +- `project_id` (String) STACKIT Project ID to which the volume automation is associated. + +### Optional + +- `region` (String) The resource region. If not defined, the provider region is used. + +### Read-Only + +- `description` (String) The volume automation description. +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`automation_id`". +- `input` (Attributes) Configuration input for the volume automation. Exactly one of the nested attributes must be set. (see [below for nested schema](#nestedatt--input)) +- `name` (String) The volume automation name. +- `template_id` (String) ID of the automation template this volume automation is based on. +- `triggers` (Attributes) Triggers that determine when the automation runs. (see [below for nested schema](#nestedatt--triggers)) + + +### Nested Schema for `input` + +Read-Only: + +- `volume_recovery_point_management` (Attributes) Configuration for automated volume recovery point (snapshot) management. (see [below for nested schema](#nestedatt--input--volume_recovery_point_management)) + + +### Nested Schema for `input.volume_recovery_point_management` + +Read-Only: + +- `inherit_volume_labels` (Boolean) Whether recovery points inherit the labels of the volume they were created from. Defaults to `false`. +- `recovery_point_labels` (Map of String) Labels to attach to created recovery points. +- `snapshot_retention_policy` (Attributes) Defines how long created recovery points (snapshots) are retained. (see [below for nested schema](#nestedatt--input--volume_recovery_point_management--snapshot_retention_policy)) +- `volume_label_selector` (String) Label selector used to select the volumes this automation applies to. + + +### Nested Schema for `input.volume_recovery_point_management.snapshot_retention_policy` + +Read-Only: + +- `kind` (String) The retention policy kind. Valid values are: `count`, `indefinitely`. +- `value` (Number) Number of recovery points to retain. Required if `kind` is `count`, must not be set otherwise. + + + + + +### Nested Schema for `triggers` + +Read-Only: + +- `schedule` (Attributes) Runs the automation on a recurring schedule. (see [below for nested schema](#nestedatt--triggers--schedule)) + + +### Nested Schema for `triggers.schedule` + +Read-Only: + +- `rrule` (String) An `rrule` (Recurrence Rule) is a standardized string format used in iCalendar (RFC 5545) to define repeating events, and you can generate one by using a dedicated library or by using online generator tools to specify parameters like frequency, interval, and end dates. diff --git a/docs/resources/volume_automation.md b/docs/resources/volume_automation.md new file mode 100644 index 000000000..63f1eab5b --- /dev/null +++ b/docs/resources/volume_automation.md @@ -0,0 +1,126 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_volume_automation Resource - stackit" +subcategory: "" +description: |- + Volume automation 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_automation (Resource) + +Volume automation 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_automation" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + template_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-volume-automation" + description = "Creates daily volume snapshots and keeps the last 7." + input = { + volume_recovery_point_management = { + inherit_volume_labels = true + recovery_point_labels = { + "created-by" = "terraform" + } + volume_label_selector = "backup=daily" + snapshot_retention_policy = { + kind = "count" + value = 4 + } + } + } + triggers = { + schedule = { + rrule = "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1" + } + } +} +``` + + +## Schema + +### Required + +- `project_id` (String) STACKIT Project ID to which the volume automation is associated. +- `template_id` (String) ID of the automation template this volume automation is based on. + +### Optional + +- `description` (String) The volume automation description. +- `input` (Attributes) Configuration input for the volume automation. Exactly one of the nested attributes must be set. (see [below for nested schema](#nestedatt--input)) +- `name` (String) The volume automation name. +- `region` (String) The resource region. If not defined, the provider region is used. +- `triggers` (Attributes) Triggers that determine when the automation runs. (see [below for nested schema](#nestedatt--triggers)) + +### Read-Only + +- `automation_id` (String) ID of the volume automation. +- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`automation_id`". + + +### Nested Schema for `input` + +Optional: + +- `volume_recovery_point_management` (Attributes) Configuration for automated volume recovery point (snapshot) management. (see [below for nested schema](#nestedatt--input--volume_recovery_point_management)) + + +### Nested Schema for `input.volume_recovery_point_management` + +Required: + +- `snapshot_retention_policy` (Attributes) Defines how long created recovery points (snapshots) are retained. (see [below for nested schema](#nestedatt--input--volume_recovery_point_management--snapshot_retention_policy)) + +Optional: + +- `inherit_volume_labels` (Boolean) Whether recovery points inherit the labels of the volume they were created from. Defaults to `false`. +- `recovery_point_labels` (Map of String) Labels to attach to created recovery points. +- `volume_label_selector` (String) Label selector used to select the volumes this automation applies to. + + +### Nested Schema for `input.volume_recovery_point_management.snapshot_retention_policy` + +Required: + +- `kind` (String) The retention policy kind. Valid values are: `count`, `indefinitely`. + +Optional: + +- `value` (Number) Number of recovery points to retain. Required if `kind` is `count`, must not be set otherwise. + + + + + +### Nested Schema for `triggers` + +Optional: + +- `schedule` (Attributes) Runs the automation on a recurring schedule. (see [below for nested schema](#nestedatt--triggers--schedule)) + + +### Nested Schema for `triggers.schedule` + +Required: + +- `rrule` (String) An `rrule` (Recurrence Rule) is a standardized string format used in iCalendar (RFC 5545) to define repeating events, and you can generate one by using a dedicated library or by using online generator tools to specify parameters like frequency, interval, and end dates. + +## Import + +Import is supported using the following syntax: + +In Terraform v1.5.0 and later, the [` + "`" + `import` + "`" + ` block](https://developer.hashicorp.com/terraform/language/import) can be used with the ` + "`" + `id` + "`" + ` attribute, for example: + +```terraform +# Only use the import statement, if you want to import an existing volume automation resource +import { + to = stackit_volume_automation.import-example + id = "${var.project_id},${var.region},${var.automation_id}" +} +``` diff --git a/examples/data-sources/stackit_volume_automation/data-source.tf b/examples/data-sources/stackit_volume_automation/data-source.tf new file mode 100644 index 000000000..1075b20a4 --- /dev/null +++ b/examples/data-sources/stackit_volume_automation/data-source.tf @@ -0,0 +1,4 @@ +data "stackit_volume_automation" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + automation_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +} diff --git a/examples/resources/stackit_volume_automation/import-by-string-id.tf b/examples/resources/stackit_volume_automation/import-by-string-id.tf new file mode 100644 index 000000000..e53efd952 --- /dev/null +++ b/examples/resources/stackit_volume_automation/import-by-string-id.tf @@ -0,0 +1,5 @@ +# Only use the import statement, if you want to import an existing volume automation resource +import { + to = stackit_volume_automation.import-example + id = "${var.project_id},${var.region},${var.automation_id}" +} diff --git a/examples/resources/stackit_volume_automation/resource.tf b/examples/resources/stackit_volume_automation/resource.tf new file mode 100644 index 000000000..6a6a344e3 --- /dev/null +++ b/examples/resources/stackit_volume_automation/resource.tf @@ -0,0 +1,24 @@ +resource "stackit_volume_automation" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + template_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-volume-automation" + description = "Creates daily volume snapshots and keeps the last 7." + input = { + volume_recovery_point_management = { + inherit_volume_labels = true + recovery_point_labels = { + "created-by" = "terraform" + } + volume_label_selector = "backup=daily" + snapshot_retention_policy = { + kind = "count" + value = 4 + } + } + } + triggers = { + schedule = { + rrule = "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1" + } + } +} diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go index cb9566b4e..67ddc52b7 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -106,6 +106,16 @@ func StringValueToPointer(s basetypes.StringValue) *string { return new(s.ValueString()) } +// StringPointerValueNullIfEmpty converts a string pointer to types.String, treating an empty +// string as null. Some APIs return "" instead of omitting unset optional fields, which would +// otherwise mismatch a null config for Optional, non-Computed attributes. +func StringPointerValueNullIfEmpty(s *string) types.String { + if s == nil || *s == "" { + return types.StringNull() + } + return types.StringValue(*s) +} + // StringValueToPointer converts basetypes.StringValue to a pointer to enum. // It returns nil if the value is null or unknown. func StringValueToEnumPointer[T ~string](s basetypes.StringValue) *T { diff --git a/stackit/internal/conversion/conversion_test.go b/stackit/internal/conversion/conversion_test.go index c330fa682..7da99913f 100644 --- a/stackit/internal/conversion/conversion_test.go +++ b/stackit/internal/conversion/conversion_test.go @@ -730,6 +730,37 @@ func TestStringValueToPointer(t *testing.T) { } } +func TestStringPointerValueNullIfEmpty(t *testing.T) { + tests := []struct { + name string + arg *string + want basetypes.StringValue + }{ + { + name: "value", + arg: new("abc"), + want: basetypes.NewStringValue("abc"), + }, + { + name: "nil", + arg: nil, + want: basetypes.NewStringNull(), + }, + { + name: "empty string treated as null", + arg: new(""), + want: basetypes.NewStringNull(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := StringPointerValueNullIfEmpty(tt.arg); !reflect.DeepEqual(got, tt.want) { + t.Errorf("StringPointerValueNullIfEmpty() = %v, want %v", got, tt.want) + } + }) + } +} + func TestStringValueToEnumPointer(t *testing.T) { type args struct { s basetypes.StringValue diff --git a/stackit/internal/services/automation/automation_acc_test.go b/stackit/internal/services/automation/automation_acc_test.go index 69f299cbd..7c50cea5b 100644 --- a/stackit/internal/services/automation/automation_acc_test.go +++ b/stackit/internal/services/automation/automation_acc_test.go @@ -1,17 +1,114 @@ package automation_test import ( + "context" _ "embed" + "errors" + "fmt" + "maps" + "net/http" "testing" + "time" "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) -//go:embed testdata/datasource-templates.tf -var templatesDataSourceConfig string +var ( + //go:embed testdata/datasource-templates.tf + templatesDataSourceConfig string + + //go:embed testdata/resource-min.tf + resourceMinConfig string + + //go:embed testdata/resource-max.tf + resourceMaxConfig string + + //go:embed testdata/resource-max-cleared.tf + resourceMaxClearedConfig string + + //go:embed testdata/datasource.tf + datasourceConfig string +) + +// lookupVolumeRecoveryPointManagementTemplateID looks up the system-provided automation +// template of kind "VolumeRecoveryPointManagement". Volume automation templates are not +// created via Terraform, so acceptance tests have to discover a valid template_id at runtime. +func lookupVolumeRecoveryPointManagementTemplateID(t *testing.T) string { + t.Helper() + ctx := context.Background() + + client, err := automation.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.AutomationCustomEndpoint, false)...) + if err != nil { + t.Fatalf("creating automation client: %v", err) + } + + templatesResp, err := client.DefaultAPI.ListVolumeTemplates(ctx, testutil.ProjectId, testutil.Region).Execute() + if err != nil { + t.Fatalf("listing volume templates: %v", err) + } + + for _, tpl := range templatesResp.Items { + templateResp, err := client.DefaultAPI.GetVolumeTemplate(ctx, testutil.ProjectId, testutil.Region, tpl.Id).Execute() + if err != nil { + t.Fatalf("getting volume template %q: %v", tpl.Id, err) + } + if templateResp.Input != nil && templateResp.Input.Kind == automation.VOLUMETEMPLATEAUTOMATIONINPUTKIND_VOLUME_RECOVERY_POINT_MANAGEMENT { + return tpl.Id + } + } + t.Fatal("no volume automation template of kind VolumeRecoveryPointManagement found") + return "" +} + +// futureRrule builds an rrule with a DTSTART relative to now, instead of a hardcoded date that +// would eventually be in the past. +func futureRrule(afterDuration time.Duration, intervalDays int) string { + dtstart := time.Now().UTC().Add(afterDuration).Format("20060102T150405") + return fmt.Sprintf("DTSTART;TZID=UTC:%s RRULE:FREQ=DAILY;INTERVAL=%d", dtstart, intervalDays) +} + +func testConfigVarsMin(templateID string) config.Variables { + return config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "template_id": config.StringVariable(templateID), + "name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), + "rrule": config.StringVariable(futureRrule(time.Hour, 1)), + } +} + +func testConfigVarsMax(templateID string) config.Variables { + return config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "template_id": config.StringVariable(templateID), + "name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), + "description": config.StringVariable("tf-acc-test description"), + "rrule": config.StringVariable(futureRrule(time.Hour, 1)), + "retention_count": config.IntegerVariable(4), + } +} + +func configVarsMinUpdated(base config.Variables) config.Variables { + tempConfig := maps.Clone(base) + tempConfig["name"] = config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)) + tempConfig["rrule"] = config.StringVariable(futureRrule(2*time.Hour, 3)) + return tempConfig +} + +func configVarsMaxUpdated(base config.Variables) config.Variables { + tempConfig := maps.Clone(base) + tempConfig["description"] = config.StringVariable("tf-acc-test description updated") + tempConfig["retention_count"] = config.IntegerVariable(5) + return tempConfig +} func TestAccAutomationTemplatesDataSource(t *testing.T) { resource.Test(t, resource.TestCase{ @@ -35,3 +132,203 @@ func TestAccAutomationTemplatesDataSource(t *testing.T) { }, }) } + +func TestAccVolumeAutomationMinResource(t *testing.T) { + varsMin := testConfigVarsMin(lookupVolumeRecoveryPointManagementTemplateID(t)) + varsMinUpdated := configVarsMinUpdated(varsMin) + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckVolumeAutomationDestroy, + Steps: []resource.TestStep{ + // Creation + { + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceMinConfig, + ConfigVariables: varsMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_volume_automation.test", "project_id", testutil.ConvertConfigVariable(varsMin["project_id"])), + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "region"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "template_id", testutil.ConvertConfigVariable(varsMin["template_id"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "name", testutil.ConvertConfigVariable(varsMin["name"])), + resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "description"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.inherit_volume_labels", "false"), + resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.recovery_point_labels.%"), + resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.volume_label_selector"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.kind", "indefinitely"), + resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.value"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMin["rrule"])), + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "automation_id"), + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "id"), + ), + }, + // data source + { + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceMinConfig + "\n" + datasourceConfig, + ConfigVariables: varsMin, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "project_id", testutil.ConvertConfigVariable(varsMin["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_volume_automation.test_data", "region"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "template_id", testutil.ConvertConfigVariable(varsMin["template_id"])), + resource.TestCheckResourceAttrSet("data.stackit_volume_automation.test_data", "automation_id"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "name", testutil.ConvertConfigVariable(varsMin["name"])), + resource.TestCheckNoResourceAttr("data.stackit_volume_automation.test_data", "description"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.inherit_volume_labels", "false"), + resource.TestCheckNoResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.recovery_point_labels.%"), + resource.TestCheckNoResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.volume_label_selector"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.snapshot_retention_policy.kind", "indefinitely"), + resource.TestCheckNoResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.snapshot_retention_policy.value"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMin["rrule"])), + resource.TestCheckResourceAttrSet("data.stackit_volume_automation.test_data", "id"), + ), + }, + // Import + { + ResourceName: "stackit_volume_automation.test", + ConfigVariables: varsMin, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_volume_automation.test"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_volume_automation.test") + } + automationId, ok := r.Primary.Attributes["automation_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute automation_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, automationId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceMinConfig, + ConfigVariables: varsMinUpdated, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "region"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "name", testutil.ConvertConfigVariable(varsMinUpdated["name"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMinUpdated["rrule"])), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccVolumeAutomationMaxResource(t *testing.T) { + varsMax := testConfigVarsMax(lookupVolumeRecoveryPointManagementTemplateID(t)) + varsMaxUpdated := configVarsMaxUpdated(varsMax) + varsMaxCleared := maps.Clone(varsMaxUpdated) + delete(varsMaxCleared, "name") + delete(varsMaxCleared, "description") + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckVolumeAutomationDestroy, + Steps: []resource.TestStep{ + // Creation + { + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceMaxConfig, + ConfigVariables: varsMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_volume_automation.test", "project_id", testutil.ConvertConfigVariable(varsMax["project_id"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "region", testutil.ConvertConfigVariable(varsMax["region"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "template_id", testutil.ConvertConfigVariable(varsMax["template_id"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "name", testutil.ConvertConfigVariable(varsMax["name"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "description", testutil.ConvertConfigVariable(varsMax["description"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.inherit_volume_labels", "true"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.recovery_point_labels.created-by", "tf-acc-test"), + resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.volume_label_selector"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.kind", "count"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.value", testutil.ConvertConfigVariable(varsMax["retention_count"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMax["rrule"])), + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "automation_id"), + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "id"), + ), + }, + // data source + { + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceMaxConfig + "\n" + datasourceConfig, + ConfigVariables: varsMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "project_id", testutil.ConvertConfigVariable(varsMax["project_id"])), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "region", testutil.ConvertConfigVariable(varsMax["region"])), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "template_id", testutil.ConvertConfigVariable(varsMax["template_id"])), + resource.TestCheckResourceAttrSet("data.stackit_volume_automation.test_data", "automation_id"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "name", testutil.ConvertConfigVariable(varsMax["name"])), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "description", testutil.ConvertConfigVariable(varsMax["description"])), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.inherit_volume_labels", "true"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.recovery_point_labels.created-by", "tf-acc-test"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.snapshot_retention_policy.kind", "count"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.snapshot_retention_policy.value", testutil.ConvertConfigVariable(varsMax["retention_count"])), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMax["rrule"])), + resource.TestCheckResourceAttrSet("data.stackit_volume_automation.test_data", "id"), + ), + }, + // Import + { + ResourceName: "stackit_volume_automation.test", + ConfigVariables: varsMax, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_volume_automation.test"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_volume_automation.test") + } + automationId, ok := r.Primary.Attributes["automation_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute automation_id") + } + return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, automationId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceMaxConfig, + ConfigVariables: varsMaxUpdated, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_volume_automation.test", "region", testutil.ConvertConfigVariable(varsMaxUpdated["region"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "description", testutil.ConvertConfigVariable(varsMaxUpdated["description"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.value", testutil.ConvertConfigVariable(varsMaxUpdated["retention_count"])), + ), + }, + // Clear optional name and description + { + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceMaxClearedConfig, + ConfigVariables: varsMaxCleared, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "name"), + resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "description"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testAccCheckVolumeAutomationDestroy(s *terraform.State) error { + ctx := context.Background() + client, err := automation.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.AutomationCustomEndpoint, false)...) + if err != nil { + return fmt.Errorf("creating automation client: %w", err) + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_volume_automation" { + continue + } + automationId := rs.Primary.Attributes["automation_id"] + if automationId == "" { + continue + } + + _, err := client.DefaultAPI.GetVolumeAutomation(ctx, testutil.ProjectId, testutil.Region, automationId).Execute() + if err == nil { + return fmt.Errorf("volume automation %s still exists", automationId) + } + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + continue + } + return fmt.Errorf("checking volume automation %s destruction: %w", automationId, err) + } + return nil +} diff --git a/stackit/internal/services/automation/testdata/datasource.tf b/stackit/internal/services/automation/testdata/datasource.tf new file mode 100644 index 000000000..d602283aa --- /dev/null +++ b/stackit/internal/services/automation/testdata/datasource.tf @@ -0,0 +1,4 @@ +data "stackit_volume_automation" "test_data" { + project_id = var.project_id + automation_id = stackit_volume_automation.test.automation_id +} diff --git a/stackit/internal/services/automation/testdata/resource-max-cleared.tf b/stackit/internal/services/automation/testdata/resource-max-cleared.tf new file mode 100644 index 000000000..e06176380 --- /dev/null +++ b/stackit/internal/services/automation/testdata/resource-max-cleared.tf @@ -0,0 +1,28 @@ +variable "project_id" {} +variable "region" {} +variable "template_id" {} +variable "rrule" {} +variable "retention_count" {} + +resource "stackit_volume_automation" "test" { + project_id = var.project_id + region = var.region + template_id = var.template_id + input = { + volume_recovery_point_management = { + inherit_volume_labels = true + recovery_point_labels = { + "created-by" = "tf-acc-test" + } + snapshot_retention_policy = { + kind = "count" + value = var.retention_count + } + } + } + triggers = { + schedule = { + rrule = var.rrule + } + } +} diff --git a/stackit/internal/services/automation/testdata/resource-max.tf b/stackit/internal/services/automation/testdata/resource-max.tf new file mode 100644 index 000000000..9a1e5edf0 --- /dev/null +++ b/stackit/internal/services/automation/testdata/resource-max.tf @@ -0,0 +1,32 @@ +variable "project_id" {} +variable "region" {} +variable "template_id" {} +variable "name" {} +variable "description" {} +variable "rrule" {} +variable "retention_count" {} + +resource "stackit_volume_automation" "test" { + project_id = var.project_id + region = var.region + template_id = var.template_id + name = var.name + description = var.description + input = { + volume_recovery_point_management = { + inherit_volume_labels = true + recovery_point_labels = { + "created-by" = "tf-acc-test" + } + snapshot_retention_policy = { + kind = "count" + value = var.retention_count + } + } + } + triggers = { + schedule = { + rrule = var.rrule + } + } +} diff --git a/stackit/internal/services/automation/testdata/resource-min.tf b/stackit/internal/services/automation/testdata/resource-min.tf new file mode 100644 index 000000000..5e12c7357 --- /dev/null +++ b/stackit/internal/services/automation/testdata/resource-min.tf @@ -0,0 +1,22 @@ +variable "project_id" {} +variable "template_id" {} +variable "name" {} +variable "rrule" {} + +resource "stackit_volume_automation" "test" { + project_id = var.project_id + template_id = var.template_id + name = var.name + input = { + volume_recovery_point_management = { + snapshot_retention_policy = { + kind = "indefinitely" + } + } + } + triggers = { + schedule = { + rrule = var.rrule + } + } +} diff --git a/stackit/internal/services/automation/volume/datasource.go b/stackit/internal/services/automation/volume/datasource.go new file mode 100644 index 000000000..0fd099895 --- /dev/null +++ b/stackit/internal/services/automation/volume/datasource.go @@ -0,0 +1,217 @@ +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" + + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + + "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" + automationUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/automation/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &volumeAutomationDataSource{} +) + +// NewVolumeAutomationDataSource is a helper function to simplify the provider implementation. +func NewVolumeAutomationDataSource() datasource.DataSource { + return &volumeAutomationDataSource{} +} + +// volumeAutomationDataSource is the data source implementation. +type volumeAutomationDataSource struct { + client *automation.APIClient + providerData core.ProviderData +} + +// Metadata returns the data source type name. +func (d *volumeAutomationDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_volume_automation" +} + +// Configure adds the provider configured client to the data source. +func (d *volumeAutomationDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_volume_automation", core.Datasource) + if resp.Diagnostics.HasError() { + return + } + + apiClient := automationUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + d.providerData = providerData + d.client = apiClient + tflog.Info(ctx, "Volume automation client configured.") +} + +// Schema defines the schema for the data source. +func (d *volumeAutomationDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Volume automation datasource schema. Must have a `region` specified in the provider configuration.", core.Datasource), + Description: "Volume automation datasource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + // the region cannot be looked up, so it has to be passed + Optional: true, + Description: descriptions["region"], + }, + "automation_id": schema.StringAttribute{ + Description: descriptions["automation_id"], + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "template_id": schema.StringAttribute{ + Description: descriptions["template_id"], + Computed: true, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Computed: true, + }, + "description": schema.StringAttribute{ + Description: descriptions["description"], + Computed: true, + }, + "input": schema.SingleNestedAttribute{ + Description: descriptions["input"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "volume_recovery_point_management": schema.SingleNestedAttribute{ + Description: descriptions["volume_recovery_point_management"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "inherit_volume_labels": schema.BoolAttribute{ + Description: descriptions["inherit_volume_labels"], + Computed: true, + }, + "recovery_point_labels": schema.MapAttribute{ + Description: descriptions["recovery_point_labels"], + ElementType: types.StringType, + Computed: true, + }, + "volume_label_selector": schema.StringAttribute{ + Description: descriptions["volume_label_selector"], + Computed: true, + }, + "snapshot_retention_policy": schema.SingleNestedAttribute{ + Description: descriptions["snapshot_retention_policy"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "kind": schema.StringAttribute{ + Description: descriptions["snapshot_retention_policy_kind"], + Computed: true, + }, + "value": schema.Int32Attribute{ + Description: descriptions["snapshot_retention_policy_value"], + Computed: true, + }, + }, + }, + }, + }, + }, + }, + "triggers": schema.SingleNestedAttribute{ + Description: descriptions["triggers"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "schedule": schema.SingleNestedAttribute{ + Description: descriptions["schedule"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "rrule": schema.StringAttribute{ + Description: descriptions["rrule"], + Computed: true, + }, + }, + }, + }, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *volumeAutomationDataSource) 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + automationId := model.AutomationId.ValueString() + region := d.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "automation_id", automationId) + ctx = tflog.SetField(ctx, "region", region) + + automationResp, err := d.client.DefaultAPI.GetVolumeAutomation(ctx, projectId, region, automationId).Execute() + if err != nil { + utils.LogError( + ctx, + &resp.Diagnostics, + err, + "Reading volume automation", + fmt.Sprintf("Volume automation with ID %q does not exist in project %q.", automationId, projectId), + map[int]string{ + http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId), + }, + ) + resp.State.RemoveResource(ctx) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(ctx, automationResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume automation", 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 automation read.") +} diff --git a/stackit/internal/services/automation/volume/resource.go b/stackit/internal/services/automation/volume/resource.go new file mode 100644 index 000000000..1509cccab --- /dev/null +++ b/stackit/internal/services/automation/volume/resource.go @@ -0,0 +1,733 @@ +package volume + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/booldefault" + "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/oapierror" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + + "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" + automationUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/automation/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &volumeAutomationResource{} + _ resource.ResourceWithConfigure = &volumeAutomationResource{} + _ resource.ResourceWithImportState = &volumeAutomationResource{} + _ resource.ResourceWithModifyPlan = &volumeAutomationResource{} + _ resource.ResourceWithValidateConfig = &volumeAutomationResource{} +) + +// Model represents the schema for the stackit_volume_automation resource and datasource. +type Model struct { + ID types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + TemplateId types.String `tfsdk:"template_id"` + AutomationId types.String `tfsdk:"automation_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Input *inputModel `tfsdk:"input"` + Triggers *triggersModel `tfsdk:"triggers"` +} + +type inputModel struct { + VolumeRecoveryPointManagement *volumeRecoveryPointManagementModel `tfsdk:"volume_recovery_point_management"` +} + +type volumeRecoveryPointManagementModel struct { + InheritVolumeLabels types.Bool `tfsdk:"inherit_volume_labels"` + RecoveryPointLabels types.Map `tfsdk:"recovery_point_labels"` + VolumeLabelSelector types.String `tfsdk:"volume_label_selector"` + SnapshotRetentionPolicy *snapshotRetentionPolicyModel `tfsdk:"snapshot_retention_policy"` +} + +type snapshotRetentionPolicyModel struct { + Kind types.String `tfsdk:"kind"` + Value types.Int32 `tfsdk:"value"` +} + +type triggersModel struct { + Schedule *scheduleTriggerModel `tfsdk:"schedule"` +} + +type scheduleTriggerModel struct { + Rrule types.String `tfsdk:"rrule"` +} + +// descriptions for the attributes in the Schema +var descriptions = map[string]string{ + "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`automation_id`\".", + "project_id": "STACKIT Project ID to which the volume automation is associated.", + "region": "The resource region. If not defined, the provider region is used.", + "template_id": "ID of the automation template this volume automation is based on.", + "automation_id": "ID of the volume automation.", + "name": "The volume automation name.", + "description": "The volume automation description.", + "input": "Configuration input for the volume automation. Exactly one of the nested attributes must be set.", + "volume_recovery_point_management": "Configuration for automated volume recovery point (snapshot) management.", + "inherit_volume_labels": "Whether recovery points inherit the labels of the volume they were created from. Defaults to `false`.", + "recovery_point_labels": "Labels to attach to created recovery points.", + "volume_label_selector": "Label selector used to select the volumes this automation applies to.", + "snapshot_retention_policy": "Defines how long created recovery points (snapshots) are retained.", + "snapshot_retention_policy_kind": "The retention policy kind. Valid values are: `count`, `indefinitely`.", + "snapshot_retention_policy_value": "Number of recovery points to retain. Required if `kind` is `count`, must not be set otherwise.", + "triggers": "Triggers that determine when the automation runs.", + "schedule": "Runs the automation on a recurring schedule.", + "rrule": "An `rrule` (Recurrence Rule) is a standardized string format used in iCalendar (RFC 5545) to define repeating events, and you can generate one by using a dedicated library or by using online generator tools to specify parameters like frequency, interval, and end dates.", +} + +// NewVolumeAutomationResource is a helper function to simplify the provider implementation. +func NewVolumeAutomationResource() resource.Resource { + return &volumeAutomationResource{} +} + +// volumeAutomationResource is the resource implementation. +type volumeAutomationResource struct { + client *automation.APIClient + providerData core.ProviderData +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *volumeAutomationResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Metadata returns the resource type name. +func (r *volumeAutomationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_volume_automation" +} + +// Configure adds the provider configured client to the resource. +func (r *volumeAutomationResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics) + if !ok { + return + } + + features.CheckBetaResourcesEnabled(ctx, &providerData, &resp.Diagnostics, "stackit_volume_automation", core.Resource) + if resp.Diagnostics.HasError() { + return + } + + apiClient := automationUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + r.providerData = providerData + r.client = apiClient + tflog.Info(ctx, "Volume automation client configured.") +} + +// Schema defines the schema for the resource. +func (r *volumeAutomationResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: features.AddBetaDescription("Volume automation resource schema. Must have a `region` specified in the provider configuration.", core.Resource), + Description: "Volume automation resource schema. Must have a `region` specified in the provider configuration.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: descriptions["id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: descriptions["project_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: descriptions["region"], + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "template_id": schema.StringAttribute{ + Description: descriptions["template_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "automation_id": schema.StringAttribute{ + Description: descriptions["automation_id"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: descriptions["name"], + Optional: true, + }, + "description": schema.StringAttribute{ + Description: descriptions["description"], + Optional: true, + }, + "input": schema.SingleNestedAttribute{ + Description: descriptions["input"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "volume_recovery_point_management": schema.SingleNestedAttribute{ + Description: descriptions["volume_recovery_point_management"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "inherit_volume_labels": schema.BoolAttribute{ + Description: descriptions["inherit_volume_labels"], + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "recovery_point_labels": schema.MapAttribute{ + Description: descriptions["recovery_point_labels"], + ElementType: types.StringType, + Optional: true, + }, + "volume_label_selector": schema.StringAttribute{ + Description: descriptions["volume_label_selector"], + Optional: true, + }, + "snapshot_retention_policy": schema.SingleNestedAttribute{ + Description: descriptions["snapshot_retention_policy"], + Required: true, + Attributes: map[string]schema.Attribute{ + "kind": schema.StringAttribute{ + Description: descriptions["snapshot_retention_policy_kind"], + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf( + string(automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT), + string(automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY), + ), + }, + }, + "value": schema.Int32Attribute{ + Description: descriptions["snapshot_retention_policy_value"], + Optional: true, + Validators: []validator.Int32{ + int32validator.AtLeast(1), + }, + }, + }, + }, + }, + }, + }, + }, + "triggers": schema.SingleNestedAttribute{ + Description: descriptions["triggers"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "schedule": schema.SingleNestedAttribute{ + Description: descriptions["schedule"], + Optional: true, + Attributes: map[string]schema.Attribute{ + "rrule": schema.StringAttribute{ + Description: descriptions["rrule"], + Required: true, + Validators: []validator.String{ + validate.Rrule(), + }, + }, + }, + }, + }, + }, + }, + } +} + +// ValidateConfig validates cross-field constraints that can't be expressed via schema validators alone. +func (r *volumeAutomationResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) + if resp.Diagnostics.HasError() { + return + } + + if model.Input == nil || model.Input.VolumeRecoveryPointManagement == nil { + return + } + srp := model.Input.VolumeRecoveryPointManagement.SnapshotRetentionPolicy + if srp == nil || utils.IsUndefined(srp.Kind) { + return + } + + valuePath := path.Root("input").AtName("volume_recovery_point_management").AtName("snapshot_retention_policy").AtName("value") + + switch srp.Kind.ValueString() { + case string(automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT): + if srp.Value.IsNull() { + resp.Diagnostics.AddAttributeError( + valuePath, + "Missing snapshot_retention_policy.value", + fmt.Sprintf("value is required when snapshot_retention_policy.kind is %q.", automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT), + ) + } + case string(automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY): + if !utils.IsUndefined(srp.Value) { + resp.Diagnostics.AddAttributeError( + valuePath, + "Unexpected snapshot_retention_policy.value", + fmt.Sprintf("value must not be set when snapshot_retention_policy.kind is %q.", automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY), + ) + } + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *volumeAutomationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toCreatePayload(ctx, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume automation", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + automationResp, err := r.client.DefaultAPI.CreateVolumeAutomation(ctx, projectId, region).CreateVolumeAutomationPayload(*payload).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume automation", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + ctx = tflog.SetField(ctx, "automation_id", automationResp.Id) + + err = mapFields(ctx, automationResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume automation", 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 automation created.") +} + +// Read refreshes the Terraform state with the latest data. +func (r *volumeAutomationResource) 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + automationId := model.AutomationId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "automation_id", automationId) + ctx = tflog.SetField(ctx, "region", region) + + automationResp, err := r.client.DefaultAPI.GetVolumeAutomation(ctx, projectId, region, automationId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume automation", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(ctx, automationResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading volume automation", 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 automation read.") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *volumeAutomationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + var plan Model + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state Model + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx = core.InitProviderContext(ctx) + + projectId := plan.ProjectId.ValueString() + automationId := state.AutomationId.ValueString() + region := r.providerData.GetRegionWithOverride(plan.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "automation_id", automationId) + ctx = tflog.SetField(ctx, "region", region) + + payload, err := toUpdatePayload(ctx, &plan) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume automation", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + automationResp, err := r.client.DefaultAPI.PartialUpdateVolumeAutomation(ctx, projectId, region, automationId). + PartialUpdateVolumeAutomationPayload(*payload). + Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume automation", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + err = mapFields(ctx, automationResp, &plan, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume automation", fmt.Sprintf("Processing API payload: %v", err)) + return + } + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + tflog.Info(ctx, "Volume automation updated.") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *volumeAutomationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // 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 + } + + ctx = core.InitProviderContext(ctx) + + projectId := model.ProjectId.ValueString() + automationId := model.AutomationId.ValueString() + region := r.providerData.GetRegionWithOverride(model.Region) + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "automation_id", automationId) + ctx = tflog.SetField(ctx, "region", region) + + err := r.client.DefaultAPI.DeleteVolumeAutomation(ctx, projectId, region, automationId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + return + } + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting volume automation", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + tflog.Info(ctx, "Volume automation deleted.") +} + +// ImportState imports a resource into the Terraform state on success. +// The expected format of the resource import identifier is: project_id,region,automation_id +func (r *volumeAutomationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, core.Separator) + if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" { + core.LogAndAddError(ctx, &resp.Diagnostics, + "Error importing volume automation", + fmt.Sprintf("Expected import identifier with format [project_id],[region],[automation_id], got %q", req.ID), + ) + return + } + + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": idParts[0], + "region": idParts[1], + "automation_id": idParts[2], + }) + + tflog.Info(ctx, "Volume automation state imported.") +} + +// mapFields maps a VolumeAutomation API response to the model. +func mapFields(ctx context.Context, apiResp *automation.VolumeAutomation, model *Model, region string) error { + if apiResp == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + model.AutomationId = types.StringValue(apiResp.Id) + model.ID = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, apiResp.Id) + model.Region = types.StringValue(region) + + if apiResp.TemplateId != nil { + model.TemplateId = types.StringValue(*apiResp.TemplateId) + } + + model.Name = conversion.StringPointerValueNullIfEmpty(apiResp.Name) + model.Description = conversion.StringPointerValueNullIfEmpty(apiResp.Description) + + input, err := mapInput(ctx, apiResp.Input, model.Input) + if err != nil { + return fmt.Errorf("mapping input: %w", err) + } + model.Input = input + + model.Triggers = mapTriggers(apiResp.Triggers) + + return nil +} + +func mapInput(ctx context.Context, apiInput *automation.VolumeAutomationInput, currentInput *inputModel) (*inputModel, error) { + if apiInput == nil || apiInput.VolumeRecoveryPointManagementInput == nil { + return nil, nil + } + vrpm := apiInput.VolumeRecoveryPointManagementInput + + currentLabels := types.MapNull(types.StringType) + if currentInput != nil && currentInput.VolumeRecoveryPointManagement != nil { + currentLabels = currentInput.VolumeRecoveryPointManagement.RecoveryPointLabels + } + + labels, err := utils.MapLabels(ctx, vrpm.RecoveryPointLabels, currentLabels) + if err != nil { + return nil, fmt.Errorf("mapping recovery point labels: %w", err) + } + + srp, err := mapSnapshotRetentionPolicy(vrpm.SnapshotRetentionPolicy) + if err != nil { + return nil, err + } + + return &inputModel{ + VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + InheritVolumeLabels: types.BoolPointerValue(vrpm.InheritVolumeLabels), + RecoveryPointLabels: labels, + VolumeLabelSelector: conversion.StringPointerValueNullIfEmpty(vrpm.VolumeLabelSelector), + SnapshotRetentionPolicy: srp, + }, + }, nil +} + +func mapSnapshotRetentionPolicy(srp automation.SnapshotRetentionPolicy) (*snapshotRetentionPolicyModel, error) { + switch { + case srp.SnapshotRetentionPolicyCount != nil: + return &snapshotRetentionPolicyModel{ + Kind: types.StringValue(string(srp.SnapshotRetentionPolicyCount.Kind)), + Value: types.Int32Value(srp.SnapshotRetentionPolicyCount.Value), + }, nil + case srp.SnapshotRetentionPolicyIndefinitely != nil: + return &snapshotRetentionPolicyModel{ + Kind: types.StringValue(string(srp.SnapshotRetentionPolicyIndefinitely.Kind)), + Value: types.Int32Null(), + }, nil + default: + return nil, fmt.Errorf("response contains an unknown snapshot retention policy variant") + } +} + +func mapTriggers(triggers *automation.AutomationTriggers) *triggersModel { + if triggers == nil || triggers.Schedule == nil { + return nil + } + return &triggersModel{ + Schedule: &scheduleTriggerModel{ + Rrule: types.StringValue(triggers.Schedule.Rrule), + }, + } +} + +func toCreatePayload(ctx context.Context, model *Model) (*automation.CreateVolumeAutomationPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + input, err := toInputPayload(ctx, model.Input) + if err != nil { + return nil, err + } + + return &automation.CreateVolumeAutomationPayload{ + TemplateId: model.TemplateId.ValueString(), + Name: conversion.StringValueToPointer(model.Name), + Description: conversion.StringValueToPointer(model.Description), + Input: input, + Triggers: toTriggersPayload(model.Triggers), + }, nil +} + +func toUpdatePayload(ctx context.Context, plan *Model) (*automation.PartialUpdateVolumeAutomationPayload, error) { + if plan == nil { + return nil, fmt.Errorf("nil plan model") + } + + input, err := toInputPayload(ctx, plan.Input) + if err != nil { + return nil, err + } + + return &automation.PartialUpdateVolumeAutomationPayload{ + // sent as explicit "" instead of omitted so clearing them actually takes effect + Name: new(plan.Name.ValueString()), + Description: new(plan.Description.ValueString()), + Input: input, + Triggers: toTriggersPayload(plan.Triggers), + }, nil +} + +func toInputPayload(ctx context.Context, model *inputModel) (*automation.VolumeAutomationInput, error) { + if model == nil || model.VolumeRecoveryPointManagement == nil { + return nil, nil + } + vrpm := model.VolumeRecoveryPointManagement + + srp, err := toSnapshotRetentionPolicyPayload(vrpm.SnapshotRetentionPolicy) + if err != nil { + return nil, err + } + + var recoveryPointLabels *map[string]string + if !utils.IsUndefined(vrpm.RecoveryPointLabels) { + labels, err := utils.LabelsToPayload(ctx, vrpm.RecoveryPointLabels) + if err != nil { + return nil, fmt.Errorf("converting recovery_point_labels: %w", err) + } + recoveryPointLabels = &labels + } + + input := automation.VolumeRecoveryPointManagementInputAsVolumeAutomationInput(&automation.VolumeRecoveryPointManagementInput{ + Kind: string(automation.VOLUMETEMPLATEAUTOMATIONINPUTKIND_VOLUME_RECOVERY_POINT_MANAGEMENT), + InheritVolumeLabels: conversion.BoolValueToPointer(vrpm.InheritVolumeLabels), + RecoveryPointLabels: recoveryPointLabels, + VolumeLabelSelector: conversion.StringValueToPointer(vrpm.VolumeLabelSelector), + SnapshotRetentionPolicy: srp, + }) + return &input, nil +} + +func toSnapshotRetentionPolicyPayload(model *snapshotRetentionPolicyModel) (automation.SnapshotRetentionPolicy, error) { + if model == nil { + return automation.SnapshotRetentionPolicy{}, fmt.Errorf("snapshot_retention_policy is required when volume_recovery_point_management is set") + } + + switch model.Kind.ValueString() { + case string(automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT): + if utils.IsUndefined(model.Value) { + return automation.SnapshotRetentionPolicy{}, fmt.Errorf("value is required when snapshot_retention_policy kind is %q", automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT) + } + return automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ + Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, + Value: model.Value.ValueInt32(), + }), nil + case string(automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY): + if !utils.IsUndefined(model.Value) { + return automation.SnapshotRetentionPolicy{}, fmt.Errorf("value must not be set when snapshot_retention_policy kind is %q", automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY) + } + return automation.SnapshotRetentionPolicyIndefinitelyAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyIndefinitely{ + Kind: automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY, + }), nil + default: + return automation.SnapshotRetentionPolicy{}, fmt.Errorf("unsupported snapshot_retention_policy kind %q", model.Kind.ValueString()) + } +} + +func toTriggersPayload(model *triggersModel) *automation.AutomationTriggers { + if model == nil || model.Schedule == nil { + return nil + } + return &automation.AutomationTriggers{ + Schedule: &automation.AutomationScheduleTrigger{ + Rrule: model.Schedule.Rrule.ValueString(), + }, + } +} diff --git a/stackit/internal/services/automation/volume/resource_test.go b/stackit/internal/services/automation/volume/resource_test.go new file mode 100644 index 000000000..bb460b92c --- /dev/null +++ b/stackit/internal/services/automation/volume/resource_test.go @@ -0,0 +1,386 @@ +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" + + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" +) + +func TestMapFields(t *testing.T) { + tests := []struct { + description string + input *automation.VolumeAutomation + expected Model + isValid bool + }{ + { + "nil_response", + nil, + Model{}, + false, + }, + { + "default_values", + &automation.VolumeAutomation{ + Id: "automation_uid", + }, + Model{ + ID: types.StringValue("project_uid,eu01,automation_uid"), + ProjectId: types.StringValue("project_uid"), + Region: types.StringValue("eu01"), + AutomationId: types.StringValue("automation_uid"), + Name: types.StringNull(), + Description: types.StringNull(), + }, + true, + }, + { + // API returns "" for unset optional fields, must be mapped to null + "empty_strings_treated_as_null", + &automation.VolumeAutomation{ + Id: "automation_uid", + Name: new(""), + Description: new(""), + Input: &automation.VolumeAutomationInput{ + VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ + Kind: "VolumeRecoveryPointManagement", + VolumeLabelSelector: new(""), + SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyIndefinitelyAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyIndefinitely{ + Kind: automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY, + }), + }, + }, + }, + Model{ + ID: types.StringValue("project_uid,eu01,automation_uid"), + ProjectId: types.StringValue("project_uid"), + Region: types.StringValue("eu01"), + AutomationId: types.StringValue("automation_uid"), + Name: types.StringNull(), + Description: types.StringNull(), + Input: &inputModel{ + VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + InheritVolumeLabels: types.BoolNull(), + RecoveryPointLabels: types.MapNull(types.StringType), + VolumeLabelSelector: types.StringNull(), + SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + Kind: types.StringValue("indefinitely"), + Value: types.Int32Null(), + }, + }, + }, + }, + true, + }, + { + "full_values_count", + &automation.VolumeAutomation{ + Id: "automation_uid", + TemplateId: new("template_uid"), + Name: new("name1"), + Description: new("desc1"), + Input: &automation.VolumeAutomationInput{ + VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ + Kind: "VolumeRecoveryPointManagement", + InheritVolumeLabels: new(true), + RecoveryPointLabels: &map[string]string{"k": "v"}, + VolumeLabelSelector: new("sel"), + SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ + Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, + Value: 7, + }), + }, + }, + Triggers: &automation.AutomationTriggers{ + Schedule: &automation.AutomationScheduleTrigger{ + Rrule: "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1", + }, + }, + }, + Model{ + ID: types.StringValue("project_uid,eu01,automation_uid"), + ProjectId: types.StringValue("project_uid"), + Region: types.StringValue("eu01"), + AutomationId: types.StringValue("automation_uid"), + TemplateId: types.StringValue("template_uid"), + Name: types.StringValue("name1"), + Description: types.StringValue("desc1"), + Input: &inputModel{ + VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + InheritVolumeLabels: types.BoolValue(true), + RecoveryPointLabels: types.MapValueMust(types.StringType, map[string]attr.Value{"k": types.StringValue("v")}), + VolumeLabelSelector: types.StringValue("sel"), + SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + Kind: types.StringValue("count"), + Value: types.Int32Value(7), + }, + }, + }, + Triggers: &triggersModel{ + Schedule: &scheduleTriggerModel{ + Rrule: types.StringValue("DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"), + }, + }, + }, + true, + }, + { + "full_values_indefinitely", + &automation.VolumeAutomation{ + Id: "automation_uid", + Input: &automation.VolumeAutomationInput{ + VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ + Kind: "VolumeRecoveryPointManagement", + SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyIndefinitelyAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyIndefinitely{ + Kind: automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY, + }), + }, + }, + }, + Model{ + ID: types.StringValue("project_uid,eu01,automation_uid"), + ProjectId: types.StringValue("project_uid"), + Region: types.StringValue("eu01"), + AutomationId: types.StringValue("automation_uid"), + Name: types.StringNull(), + Description: types.StringNull(), + Input: &inputModel{ + VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + InheritVolumeLabels: types.BoolNull(), + RecoveryPointLabels: types.MapNull(types.StringType), + VolumeLabelSelector: types.StringNull(), + SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + Kind: types.StringValue("indefinitely"), + Value: types.Int32Null(), + }, + }, + }, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + state := &Model{ + ProjectId: tt.expected.ProjectId, + } + ctx := context.TODO() + err := mapFields(ctx, tt.input, state, "eu01") + 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(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 *automation.CreateVolumeAutomationPayload + isValid bool + }{ + { + "nil_model", + nil, + nil, + false, + }, + { + "minimal", + &Model{ + TemplateId: types.StringValue("template_uid"), + }, + &automation.CreateVolumeAutomationPayload{ + TemplateId: "template_uid", + }, + true, + }, + { + "full_values_count", + &Model{ + TemplateId: types.StringValue("template_uid"), + Name: types.StringValue("name1"), + Description: types.StringValue("desc1"), + Input: &inputModel{ + VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + InheritVolumeLabels: types.BoolValue(true), + RecoveryPointLabels: types.MapValueMust(types.StringType, map[string]attr.Value{"k": types.StringValue("v")}), + VolumeLabelSelector: types.StringValue("sel"), + SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + Kind: types.StringValue("count"), + Value: types.Int32Value(7), + }, + }, + }, + Triggers: &triggersModel{ + Schedule: &scheduleTriggerModel{ + Rrule: types.StringValue("RRULE"), + }, + }, + }, + &automation.CreateVolumeAutomationPayload{ + TemplateId: "template_uid", + Name: new("name1"), + Description: new("desc1"), + Input: &automation.VolumeAutomationInput{ + VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ + Kind: "VolumeRecoveryPointManagement", + InheritVolumeLabels: new(true), + RecoveryPointLabels: &map[string]string{"k": "v"}, + VolumeLabelSelector: new("sel"), + SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ + Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, + Value: 7, + }), + }, + }, + Triggers: &automation.AutomationTriggers{ + Schedule: &automation.AutomationScheduleTrigger{ + Rrule: "RRULE", + }, + }, + }, + true, + }, + { + "count_kind_missing_value", + &Model{ + TemplateId: types.StringValue("template_uid"), + Input: &inputModel{ + VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + Kind: types.StringValue("count"), + }, + }, + }, + }, + nil, + false, + }, + { + "missing_snapshot_retention_policy", + &Model{ + TemplateId: types.StringValue("template_uid"), + Input: &inputModel{ + VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{}, + }, + }, + nil, + false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + ctx := context.TODO() + output, err := toCreatePayload(ctx, 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) { + baseInput := &inputModel{ + VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + Kind: types.StringValue("count"), + Value: types.Int32Value(3), + }, + }, + } + + tests := []struct { + description string + plan *Model + expected *automation.PartialUpdateVolumeAutomationPayload + isValid bool + }{ + { + "nil_plan", + nil, + nil, + false, + }, + { + "full_values", + &Model{ + Name: types.StringValue("n"), + Description: types.StringValue("d"), + Input: baseInput, + Triggers: &triggersModel{ + Schedule: &scheduleTriggerModel{Rrule: types.StringValue("RRULE")}, + }, + }, + &automation.PartialUpdateVolumeAutomationPayload{ + Name: new("n"), + Description: new("d"), + Input: &automation.VolumeAutomationInput{ + VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ + Kind: "VolumeRecoveryPointManagement", + SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ + Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, + Value: 3, + }), + }, + }, + Triggers: &automation.AutomationTriggers{ + Schedule: &automation.AutomationScheduleTrigger{Rrule: "RRULE"}, + }, + }, + true, + }, + { + // clearing must send explicit "" instead of omitting the field + "clears_optional_string_fields", + &Model{Name: types.StringNull(), Description: types.StringNull()}, + &automation.PartialUpdateVolumeAutomationPayload{ + Name: new(""), + Description: new(""), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + ctx := context.TODO() + output, err := toUpdatePayload(ctx, tt.plan) + 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 bcf1eda62..9328820ea 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -90,6 +90,7 @@ var ( ModelServingCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MODELSERVING_CUSTOM_ENDPOINT", providerName: "modelserving_custom_endpoint"} ModelExperimentsCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MODELEXPERIMENTS_CUSTOM_ENDPOINT", providerName: "modelexperiments_custom_endpoint"} AuthorizationCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_AUTHORIZATION_CUSTOM_ENDPOINT", providerName: "authorization_custom_endpoint"} + AutomationCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_AUTOMATION_CUSTOM_ENDPOINT", providerName: "automation_custom_endpoint"} MongoDBFlexCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_MONGODBFLEX_CUSTOM_ENDPOINT", providerName: "mongodbflex_custom_endpoint"} OpenSearchCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_OPENSEARCH_CUSTOM_ENDPOINT", providerName: "opensearch_custom_endpoint"} ObservabilityCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_OBSERVABILITY_CUSTOM_ENDPOINT", providerName: "observability_custom_endpoint"} @@ -131,6 +132,7 @@ var ( ModelServingCustomEndpoint, ModelExperimentsCustomEndpoint, AuthorizationCustomEndpoint, + AutomationCustomEndpoint, MongoDBFlexCustomEndpoint, OpenSearchCustomEndpoint, ObservabilityCustomEndpoint, diff --git a/stackit/provider.go b/stackit/provider.go index 59d1cee5d..c3ec02597 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -27,6 +27,7 @@ import ( customRole "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/customrole" roleAssignements "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/authorization/roleassignments" automationTemplates "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/automation/templates" + automationVolume "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/automation/volume" cdnCustomDomain "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/customdomain" cdn "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/cdn/distribution" dnsRecordSet "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/recordset" @@ -703,6 +704,7 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource albWafManagedRuleSet.NewManagedRuleSetDataSource, alertGroup.NewAlertGroupDataSource, automationTemplates.NewAutomationTemplatesDataSource, + automationVolume.NewVolumeAutomationDataSource, cdn.NewDistributionDataSource, cert.NewCertificatesDataSource, cdnCustomDomain.NewCustomDomainDataSource, @@ -825,6 +827,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { albWaf.NewWafConfigurationResource, albWafManagedRuleSet.NewManagedRuleSetResource, alertGroup.NewAlertGroupResource, + automationVolume.NewVolumeAutomationResource, cdn.NewDistributionResource, cert.NewCertificatesResource, cdnCustomDomain.NewCustomDomainResource, From b04bc51d573c0ed52c756a2dbc144d2d1c123b22 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Mon, 21 Sep 2026 16:07:36 +0200 Subject: [PATCH 2/5] update to use open input object --- docs/data-sources/volume_automation.md | 30 +- docs/resources/volume_automation.md | 36 +- stackit/internal/conversion/conversion.go | 10 - .../internal/conversion/conversion_test.go | 31 -- .../automation/automation_acc_test.go | 119 +++-- .../testdata/resource-max-cleared.tf | 15 +- .../automation/testdata/resource-max.tf | 15 +- .../automation/testdata/resource-min.tf | 9 +- .../services/automation/volume/datasource.go | 40 +- .../services/automation/volume/resource.go | 342 ++++--------- .../automation/volume/resource_test.go | 456 +++++++++--------- 11 files changed, 417 insertions(+), 686 deletions(-) diff --git a/docs/data-sources/volume_automation.md b/docs/data-sources/volume_automation.md index 5ace58b76..152f0411b 100644 --- a/docs/data-sources/volume_automation.md +++ b/docs/data-sources/volume_automation.md @@ -38,39 +38,11 @@ data "stackit_volume_automation" "example" { - `description` (String) The volume automation description. - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`automation_id`". -- `input` (Attributes) Configuration input for the volume automation. Exactly one of the nested attributes must be set. (see [below for nested schema](#nestedatt--input)) +- `input` (String) Configuration input for the volume automation. Exactly one of the nested attributes must be set. - `name` (String) The volume automation name. - `template_id` (String) ID of the automation template this volume automation is based on. - `triggers` (Attributes) Triggers that determine when the automation runs. (see [below for nested schema](#nestedatt--triggers)) - -### Nested Schema for `input` - -Read-Only: - -- `volume_recovery_point_management` (Attributes) Configuration for automated volume recovery point (snapshot) management. (see [below for nested schema](#nestedatt--input--volume_recovery_point_management)) - - -### Nested Schema for `input.volume_recovery_point_management` - -Read-Only: - -- `inherit_volume_labels` (Boolean) Whether recovery points inherit the labels of the volume they were created from. Defaults to `false`. -- `recovery_point_labels` (Map of String) Labels to attach to created recovery points. -- `snapshot_retention_policy` (Attributes) Defines how long created recovery points (snapshots) are retained. (see [below for nested schema](#nestedatt--input--volume_recovery_point_management--snapshot_retention_policy)) -- `volume_label_selector` (String) Label selector used to select the volumes this automation applies to. - - -### Nested Schema for `input.volume_recovery_point_management.snapshot_retention_policy` - -Read-Only: - -- `kind` (String) The retention policy kind. Valid values are: `count`, `indefinitely`. -- `value` (Number) Number of recovery points to retain. Required if `kind` is `count`, must not be set otherwise. - - - - ### Nested Schema for `triggers` diff --git a/docs/resources/volume_automation.md b/docs/resources/volume_automation.md index 63f1eab5b..2df080efb 100644 --- a/docs/resources/volume_automation.md +++ b/docs/resources/volume_automation.md @@ -53,7 +53,7 @@ resource "stackit_volume_automation" "example" { ### Optional - `description` (String) The volume automation description. -- `input` (Attributes) Configuration input for the volume automation. Exactly one of the nested attributes must be set. (see [below for nested schema](#nestedatt--input)) +- `input` (String) Configuration input for the volume automation. Exactly one of the nested attributes must be set. - `name` (String) The volume automation name. - `region` (String) The resource region. If not defined, the provider region is used. - `triggers` (Attributes) Triggers that determine when the automation runs. (see [below for nested schema](#nestedatt--triggers)) @@ -63,40 +63,6 @@ resource "stackit_volume_automation" "example" { - `automation_id` (String) ID of the volume automation. - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`automation_id`". - -### Nested Schema for `input` - -Optional: - -- `volume_recovery_point_management` (Attributes) Configuration for automated volume recovery point (snapshot) management. (see [below for nested schema](#nestedatt--input--volume_recovery_point_management)) - - -### Nested Schema for `input.volume_recovery_point_management` - -Required: - -- `snapshot_retention_policy` (Attributes) Defines how long created recovery points (snapshots) are retained. (see [below for nested schema](#nestedatt--input--volume_recovery_point_management--snapshot_retention_policy)) - -Optional: - -- `inherit_volume_labels` (Boolean) Whether recovery points inherit the labels of the volume they were created from. Defaults to `false`. -- `recovery_point_labels` (Map of String) Labels to attach to created recovery points. -- `volume_label_selector` (String) Label selector used to select the volumes this automation applies to. - - -### Nested Schema for `input.volume_recovery_point_management.snapshot_retention_policy` - -Required: - -- `kind` (String) The retention policy kind. Valid values are: `count`, `indefinitely`. - -Optional: - -- `value` (Number) Number of recovery points to retain. Required if `kind` is `count`, must not be set otherwise. - - - - ### Nested Schema for `triggers` diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go index 67ddc52b7..cb9566b4e 100644 --- a/stackit/internal/conversion/conversion.go +++ b/stackit/internal/conversion/conversion.go @@ -106,16 +106,6 @@ func StringValueToPointer(s basetypes.StringValue) *string { return new(s.ValueString()) } -// StringPointerValueNullIfEmpty converts a string pointer to types.String, treating an empty -// string as null. Some APIs return "" instead of omitting unset optional fields, which would -// otherwise mismatch a null config for Optional, non-Computed attributes. -func StringPointerValueNullIfEmpty(s *string) types.String { - if s == nil || *s == "" { - return types.StringNull() - } - return types.StringValue(*s) -} - // StringValueToPointer converts basetypes.StringValue to a pointer to enum. // It returns nil if the value is null or unknown. func StringValueToEnumPointer[T ~string](s basetypes.StringValue) *T { diff --git a/stackit/internal/conversion/conversion_test.go b/stackit/internal/conversion/conversion_test.go index 7da99913f..c330fa682 100644 --- a/stackit/internal/conversion/conversion_test.go +++ b/stackit/internal/conversion/conversion_test.go @@ -730,37 +730,6 @@ func TestStringValueToPointer(t *testing.T) { } } -func TestStringPointerValueNullIfEmpty(t *testing.T) { - tests := []struct { - name string - arg *string - want basetypes.StringValue - }{ - { - name: "value", - arg: new("abc"), - want: basetypes.NewStringValue("abc"), - }, - { - name: "nil", - arg: nil, - want: basetypes.NewStringNull(), - }, - { - name: "empty string treated as null", - arg: new(""), - want: basetypes.NewStringNull(), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := StringPointerValueNullIfEmpty(tt.arg); !reflect.DeepEqual(got, tt.want) { - t.Errorf("StringPointerValueNullIfEmpty() = %v, want %v", got, tt.want) - } - }) - } -} - func TestStringValueToEnumPointer(t *testing.T) { type args struct { s basetypes.StringValue diff --git a/stackit/internal/services/automation/automation_acc_test.go b/stackit/internal/services/automation/automation_acc_test.go index 7c50cea5b..1a2686e24 100644 --- a/stackit/internal/services/automation/automation_acc_test.go +++ b/stackit/internal/services/automation/automation_acc_test.go @@ -3,6 +3,7 @@ package automation_test import ( "context" _ "embed" + "encoding/json" "errors" "fmt" "maps" @@ -76,23 +77,53 @@ func futureRrule(afterDuration time.Duration, intervalDays int) string { } func testConfigVarsMin(templateID string) config.Variables { + // the input variable needs a json decoded string + inputMap := map[string]interface{}{ + "kind": "VolumeRecoveryPointManagement", + "snapshotRetentionPolicy": map[string]interface{}{ + "kind": "indefinitely", + }, + } + inputJson, err := json.Marshal(inputMap) + if err != nil { + return nil + } + return config.Variables{ "project_id": config.StringVariable(testutil.ProjectId), "template_id": config.StringVariable(templateID), "name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), "rrule": config.StringVariable(futureRrule(time.Hour, 1)), + "input": config.StringVariable(string(inputJson)), } } func testConfigVarsMax(templateID string) config.Variables { + // the input variable needs a json decoded string + inputMap := map[string]interface{}{ + "kind": "VolumeRecoveryPointManagement", + "inheritVolumeLabels": true, + "recoveryPointLabels": map[string]interface{}{ + "created-by": "tf-acc-test", + }, + "snapshotRetentionPolicy": map[string]interface{}{ + "kind": "count", + "value": 4, + }, + } + inputJson, err := json.Marshal(inputMap) + if err != nil { + return nil + } + return config.Variables{ - "project_id": config.StringVariable(testutil.ProjectId), - "region": config.StringVariable(testutil.Region), - "template_id": config.StringVariable(templateID), - "name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), - "description": config.StringVariable("tf-acc-test description"), - "rrule": config.StringVariable(futureRrule(time.Hour, 1)), - "retention_count": config.IntegerVariable(4), + "project_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "template_id": config.StringVariable(templateID), + "name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), + "description": config.StringVariable("tf-acc-test description"), + "rrule": config.StringVariable(futureRrule(time.Hour, 1)), + "input": config.StringVariable(string(inputJson)), } } @@ -104,9 +135,25 @@ func configVarsMinUpdated(base config.Variables) config.Variables { } func configVarsMaxUpdated(base config.Variables) config.Variables { + // the input variable needs a json decoded string + inputMap := map[string]interface{}{ + "kind": "VolumeRecoveryPointManagement", + "recoveryPointLabels": map[string]interface{}{ + "created-by": "tf-acc-test-updated", + }, + "snapshotRetentionPolicy": map[string]interface{}{ + "kind": "indefinitely", + }, + } + inputJson, err := json.Marshal(inputMap) + if err != nil { + return nil + } + tempConfig := maps.Clone(base) tempConfig["description"] = config.StringVariable("tf-acc-test description updated") tempConfig["retention_count"] = config.IntegerVariable(5) + tempConfig["input"] = config.StringVariable(string(inputJson)) return tempConfig } @@ -150,11 +197,7 @@ func TestAccVolumeAutomationMinResource(t *testing.T) { resource.TestCheckResourceAttr("stackit_volume_automation.test", "template_id", testutil.ConvertConfigVariable(varsMin["template_id"])), resource.TestCheckResourceAttr("stackit_volume_automation.test", "name", testutil.ConvertConfigVariable(varsMin["name"])), resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "description"), - resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.inherit_volume_labels", "false"), - resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.recovery_point_labels.%"), - resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.volume_label_selector"), - resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.kind", "indefinitely"), - resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.value"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input", testutil.ConvertConfigVariable(varsMin["input"])), resource.TestCheckResourceAttr("stackit_volume_automation.test", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMin["rrule"])), resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "automation_id"), resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "id"), @@ -171,11 +214,7 @@ func TestAccVolumeAutomationMinResource(t *testing.T) { resource.TestCheckResourceAttrSet("data.stackit_volume_automation.test_data", "automation_id"), resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "name", testutil.ConvertConfigVariable(varsMin["name"])), resource.TestCheckNoResourceAttr("data.stackit_volume_automation.test_data", "description"), - resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.inherit_volume_labels", "false"), - resource.TestCheckNoResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.recovery_point_labels.%"), - resource.TestCheckNoResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.volume_label_selector"), - resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.snapshot_retention_policy.kind", "indefinitely"), - resource.TestCheckNoResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.snapshot_retention_policy.value"), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input", testutil.ConvertConfigVariable(varsMin["input"])), resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMin["rrule"])), resource.TestCheckResourceAttrSet("data.stackit_volume_automation.test_data", "id"), ), @@ -203,9 +242,15 @@ func TestAccVolumeAutomationMinResource(t *testing.T) { Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceMinConfig, ConfigVariables: varsMinUpdated, Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_volume_automation.test", "project_id", testutil.ConvertConfigVariable(varsMinUpdated["project_id"])), resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "region"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "template_id", testutil.ConvertConfigVariable(varsMinUpdated["template_id"])), resource.TestCheckResourceAttr("stackit_volume_automation.test", "name", testutil.ConvertConfigVariable(varsMinUpdated["name"])), + resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "description"), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input", testutil.ConvertConfigVariable(varsMinUpdated["input"])), resource.TestCheckResourceAttr("stackit_volume_automation.test", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMinUpdated["rrule"])), + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "automation_id"), + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "id"), ), }, // Deletion is done by the framework implicitly @@ -233,11 +278,7 @@ func TestAccVolumeAutomationMaxResource(t *testing.T) { resource.TestCheckResourceAttr("stackit_volume_automation.test", "template_id", testutil.ConvertConfigVariable(varsMax["template_id"])), resource.TestCheckResourceAttr("stackit_volume_automation.test", "name", testutil.ConvertConfigVariable(varsMax["name"])), resource.TestCheckResourceAttr("stackit_volume_automation.test", "description", testutil.ConvertConfigVariable(varsMax["description"])), - resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.inherit_volume_labels", "true"), - resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.recovery_point_labels.created-by", "tf-acc-test"), - resource.TestCheckNoResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.volume_label_selector"), - resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.kind", "count"), - resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.value", testutil.ConvertConfigVariable(varsMax["retention_count"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input", testutil.ConvertConfigVariable(varsMax["input"])), resource.TestCheckResourceAttr("stackit_volume_automation.test", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMax["rrule"])), resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "automation_id"), resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "id"), @@ -254,10 +295,7 @@ func TestAccVolumeAutomationMaxResource(t *testing.T) { resource.TestCheckResourceAttrSet("data.stackit_volume_automation.test_data", "automation_id"), resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "name", testutil.ConvertConfigVariable(varsMax["name"])), resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "description", testutil.ConvertConfigVariable(varsMax["description"])), - resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.inherit_volume_labels", "true"), - resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.recovery_point_labels.created-by", "tf-acc-test"), - resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.snapshot_retention_policy.kind", "count"), - resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input.volume_recovery_point_management.snapshot_retention_policy.value", testutil.ConvertConfigVariable(varsMax["retention_count"])), + resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "input", testutil.ConvertConfigVariable(varsMax["input"])), resource.TestCheckResourceAttr("data.stackit_volume_automation.test_data", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMax["rrule"])), resource.TestCheckResourceAttrSet("data.stackit_volume_automation.test_data", "id"), ), @@ -285,9 +323,15 @@ func TestAccVolumeAutomationMaxResource(t *testing.T) { Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + "\n" + resourceMaxConfig, ConfigVariables: varsMaxUpdated, Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_volume_automation.test", "project_id", testutil.ConvertConfigVariable(varsMaxUpdated["project_id"])), resource.TestCheckResourceAttr("stackit_volume_automation.test", "region", testutil.ConvertConfigVariable(varsMaxUpdated["region"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "template_id", testutil.ConvertConfigVariable(varsMaxUpdated["template_id"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "name", testutil.ConvertConfigVariable(varsMaxUpdated["name"])), resource.TestCheckResourceAttr("stackit_volume_automation.test", "description", testutil.ConvertConfigVariable(varsMaxUpdated["description"])), - resource.TestCheckResourceAttr("stackit_volume_automation.test", "input.volume_recovery_point_management.snapshot_retention_policy.value", testutil.ConvertConfigVariable(varsMaxUpdated["retention_count"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "input", testutil.ConvertConfigVariable(varsMaxUpdated["input"])), + resource.TestCheckResourceAttr("stackit_volume_automation.test", "triggers.schedule.rrule", testutil.ConvertConfigVariable(varsMaxUpdated["rrule"])), + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "automation_id"), + resource.TestCheckResourceAttrSet("stackit_volume_automation.test", "id"), ), }, // Clear optional name and description @@ -311,24 +355,33 @@ func testAccCheckVolumeAutomationDestroy(s *terraform.State) error { return fmt.Errorf("creating automation client: %w", err) } + var errs []error for _, rs := range s.RootModule().Resources { if rs.Type != "stackit_volume_automation" { continue } + projectId := rs.Primary.Attributes["project_id"] + if projectId == "" { + continue + } + region := rs.Primary.Attributes["region"] + if region == "" { + continue + } automationId := rs.Primary.Attributes["automation_id"] if automationId == "" { continue } - _, err := client.DefaultAPI.GetVolumeAutomation(ctx, testutil.ProjectId, testutil.Region, automationId).Execute() + _, err = client.DefaultAPI.GetVolumeAutomation(ctx, projectId, region, automationId).Execute() if err == nil { - return fmt.Errorf("volume automation %s still exists", automationId) + errs = append(errs, fmt.Errorf("volume automation %s still exists", automationId)) + continue } - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { continue } - return fmt.Errorf("checking volume automation %s destruction: %w", automationId, err) + errs = append(errs, fmt.Errorf("checking volume automation %s destruction: %w", automationId, err)) } - return nil + return errors.Join(errs...) } diff --git a/stackit/internal/services/automation/testdata/resource-max-cleared.tf b/stackit/internal/services/automation/testdata/resource-max-cleared.tf index e06176380..b9bcb13c7 100644 --- a/stackit/internal/services/automation/testdata/resource-max-cleared.tf +++ b/stackit/internal/services/automation/testdata/resource-max-cleared.tf @@ -2,24 +2,13 @@ variable "project_id" {} variable "region" {} variable "template_id" {} variable "rrule" {} -variable "retention_count" {} +variable "input" {} resource "stackit_volume_automation" "test" { project_id = var.project_id region = var.region template_id = var.template_id - input = { - volume_recovery_point_management = { - inherit_volume_labels = true - recovery_point_labels = { - "created-by" = "tf-acc-test" - } - snapshot_retention_policy = { - kind = "count" - value = var.retention_count - } - } - } + input = var.input triggers = { schedule = { rrule = var.rrule diff --git a/stackit/internal/services/automation/testdata/resource-max.tf b/stackit/internal/services/automation/testdata/resource-max.tf index 9a1e5edf0..36bb71bcb 100644 --- a/stackit/internal/services/automation/testdata/resource-max.tf +++ b/stackit/internal/services/automation/testdata/resource-max.tf @@ -4,7 +4,7 @@ variable "template_id" {} variable "name" {} variable "description" {} variable "rrule" {} -variable "retention_count" {} +variable "input" {} resource "stackit_volume_automation" "test" { project_id = var.project_id @@ -12,18 +12,7 @@ resource "stackit_volume_automation" "test" { template_id = var.template_id name = var.name description = var.description - input = { - volume_recovery_point_management = { - inherit_volume_labels = true - recovery_point_labels = { - "created-by" = "tf-acc-test" - } - snapshot_retention_policy = { - kind = "count" - value = var.retention_count - } - } - } + input = var.input triggers = { schedule = { rrule = var.rrule diff --git a/stackit/internal/services/automation/testdata/resource-min.tf b/stackit/internal/services/automation/testdata/resource-min.tf index 5e12c7357..2f46d382f 100644 --- a/stackit/internal/services/automation/testdata/resource-min.tf +++ b/stackit/internal/services/automation/testdata/resource-min.tf @@ -2,18 +2,13 @@ variable "project_id" {} variable "template_id" {} variable "name" {} variable "rrule" {} +variable "input" {} resource "stackit_volume_automation" "test" { project_id = var.project_id template_id = var.template_id name = var.name - input = { - volume_recovery_point_management = { - snapshot_retention_policy = { - kind = "indefinitely" - } - } - } + input = var.input triggers = { schedule = { rrule = var.rrule diff --git a/stackit/internal/services/automation/volume/datasource.go b/stackit/internal/services/automation/volume/datasource.go index 0fd099895..1ab8aab37 100644 --- a/stackit/internal/services/automation/volume/datasource.go +++ b/stackit/internal/services/automation/volume/datasource.go @@ -5,10 +5,10 @@ import ( "fmt" "net/http" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "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" automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" @@ -106,44 +106,10 @@ func (d *volumeAutomationDataSource) Schema(_ context.Context, _ datasource.Sche Description: descriptions["description"], Computed: true, }, - "input": schema.SingleNestedAttribute{ + "input": schema.StringAttribute{ + CustomType: jsontypes.NormalizedType{}, Description: descriptions["input"], Computed: true, - Attributes: map[string]schema.Attribute{ - "volume_recovery_point_management": schema.SingleNestedAttribute{ - Description: descriptions["volume_recovery_point_management"], - Computed: true, - Attributes: map[string]schema.Attribute{ - "inherit_volume_labels": schema.BoolAttribute{ - Description: descriptions["inherit_volume_labels"], - Computed: true, - }, - "recovery_point_labels": schema.MapAttribute{ - Description: descriptions["recovery_point_labels"], - ElementType: types.StringType, - Computed: true, - }, - "volume_label_selector": schema.StringAttribute{ - Description: descriptions["volume_label_selector"], - Computed: true, - }, - "snapshot_retention_policy": schema.SingleNestedAttribute{ - Description: descriptions["snapshot_retention_policy"], - Computed: true, - Attributes: map[string]schema.Attribute{ - "kind": schema.StringAttribute{ - Description: descriptions["snapshot_retention_policy_kind"], - Computed: true, - }, - "value": schema.Int32Attribute{ - Description: descriptions["snapshot_retention_policy_value"], - Computed: true, - }, - }, - }, - }, - }, - }, }, "triggers": schema.SingleNestedAttribute{ Description: descriptions["triggers"], diff --git a/stackit/internal/services/automation/volume/resource.go b/stackit/internal/services/automation/volume/resource.go index 1509cccab..c82d30c83 100644 --- a/stackit/internal/services/automation/volume/resource.go +++ b/stackit/internal/services/automation/volume/resource.go @@ -2,17 +2,15 @@ package volume import ( "context" + "encoding/json" "errors" "fmt" "net/http" "strings" - "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "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" @@ -32,40 +30,23 @@ import ( // Ensure the implementation satisfies the expected interfaces. var ( - _ resource.Resource = &volumeAutomationResource{} - _ resource.ResourceWithConfigure = &volumeAutomationResource{} - _ resource.ResourceWithImportState = &volumeAutomationResource{} - _ resource.ResourceWithModifyPlan = &volumeAutomationResource{} - _ resource.ResourceWithValidateConfig = &volumeAutomationResource{} + _ resource.Resource = &volumeAutomationResource{} + _ resource.ResourceWithConfigure = &volumeAutomationResource{} + _ resource.ResourceWithImportState = &volumeAutomationResource{} + _ resource.ResourceWithModifyPlan = &volumeAutomationResource{} ) // Model represents the schema for the stackit_volume_automation resource and datasource. type Model struct { - ID types.String `tfsdk:"id"` - ProjectId types.String `tfsdk:"project_id"` - Region types.String `tfsdk:"region"` - TemplateId types.String `tfsdk:"template_id"` - AutomationId types.String `tfsdk:"automation_id"` - Name types.String `tfsdk:"name"` - Description types.String `tfsdk:"description"` - Input *inputModel `tfsdk:"input"` - Triggers *triggersModel `tfsdk:"triggers"` -} - -type inputModel struct { - VolumeRecoveryPointManagement *volumeRecoveryPointManagementModel `tfsdk:"volume_recovery_point_management"` -} - -type volumeRecoveryPointManagementModel struct { - InheritVolumeLabels types.Bool `tfsdk:"inherit_volume_labels"` - RecoveryPointLabels types.Map `tfsdk:"recovery_point_labels"` - VolumeLabelSelector types.String `tfsdk:"volume_label_selector"` - SnapshotRetentionPolicy *snapshotRetentionPolicyModel `tfsdk:"snapshot_retention_policy"` -} - -type snapshotRetentionPolicyModel struct { - Kind types.String `tfsdk:"kind"` - Value types.Int32 `tfsdk:"value"` + ID types.String `tfsdk:"id"` + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + TemplateId types.String `tfsdk:"template_id"` + AutomationId types.String `tfsdk:"automation_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Input jsontypes.Normalized `tfsdk:"input"` + Triggers *triggersModel `tfsdk:"triggers"` } type triggersModel struct { @@ -85,7 +66,7 @@ var descriptions = map[string]string{ "automation_id": "ID of the volume automation.", "name": "The volume automation name.", "description": "The volume automation description.", - "input": "Configuration input for the volume automation. Exactly one of the nested attributes must be set.", + "input": "Configuration input for the volume automation. Exactly one of the nested attributes must be set.", // TODO: update description "volume_recovery_point_management": "Configuration for automated volume recovery point (snapshot) management.", "inherit_volume_labels": "Whether recovery points inherit the labels of the volume they were created from. Defaults to `false`.", "recovery_point_labels": "Labels to attach to created recovery points.", @@ -225,55 +206,10 @@ func (r *volumeAutomationResource) Schema(_ context.Context, _ resource.SchemaRe Description: descriptions["description"], Optional: true, }, - "input": schema.SingleNestedAttribute{ + "input": schema.StringAttribute{ + CustomType: jsontypes.NormalizedType{}, Description: descriptions["input"], Optional: true, - Attributes: map[string]schema.Attribute{ - "volume_recovery_point_management": schema.SingleNestedAttribute{ - Description: descriptions["volume_recovery_point_management"], - Optional: true, - Attributes: map[string]schema.Attribute{ - "inherit_volume_labels": schema.BoolAttribute{ - Description: descriptions["inherit_volume_labels"], - Optional: true, - Computed: true, - Default: booldefault.StaticBool(false), - }, - "recovery_point_labels": schema.MapAttribute{ - Description: descriptions["recovery_point_labels"], - ElementType: types.StringType, - Optional: true, - }, - "volume_label_selector": schema.StringAttribute{ - Description: descriptions["volume_label_selector"], - Optional: true, - }, - "snapshot_retention_policy": schema.SingleNestedAttribute{ - Description: descriptions["snapshot_retention_policy"], - Required: true, - Attributes: map[string]schema.Attribute{ - "kind": schema.StringAttribute{ - Description: descriptions["snapshot_retention_policy_kind"], - Required: true, - Validators: []validator.String{ - stringvalidator.OneOf( - string(automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT), - string(automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY), - ), - }, - }, - "value": schema.Int32Attribute{ - Description: descriptions["snapshot_retention_policy_value"], - Optional: true, - Validators: []validator.Int32{ - int32validator.AtLeast(1), - }, - }, - }, - }, - }, - }, - }, }, "triggers": schema.SingleNestedAttribute{ Description: descriptions["triggers"], @@ -298,44 +234,6 @@ func (r *volumeAutomationResource) Schema(_ context.Context, _ resource.SchemaRe } } -// ValidateConfig validates cross-field constraints that can't be expressed via schema validators alone. -func (r *volumeAutomationResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { // nolint:gocritic // function signature required by Terraform - var model Model - resp.Diagnostics.Append(req.Config.Get(ctx, &model)...) - if resp.Diagnostics.HasError() { - return - } - - if model.Input == nil || model.Input.VolumeRecoveryPointManagement == nil { - return - } - srp := model.Input.VolumeRecoveryPointManagement.SnapshotRetentionPolicy - if srp == nil || utils.IsUndefined(srp.Kind) { - return - } - - valuePath := path.Root("input").AtName("volume_recovery_point_management").AtName("snapshot_retention_policy").AtName("value") - - switch srp.Kind.ValueString() { - case string(automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT): - if srp.Value.IsNull() { - resp.Diagnostics.AddAttributeError( - valuePath, - "Missing snapshot_retention_policy.value", - fmt.Sprintf("value is required when snapshot_retention_policy.kind is %q.", automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT), - ) - } - case string(automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY): - if !utils.IsUndefined(srp.Value) { - resp.Diagnostics.AddAttributeError( - valuePath, - "Unexpected snapshot_retention_policy.value", - fmt.Sprintf("value must not be set when snapshot_retention_policy.kind is %q.", automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY), - ) - } - } -} - // Create creates the resource and sets the initial Terraform state. func (r *volumeAutomationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform var model Model @@ -353,7 +251,7 @@ func (r *volumeAutomationResource) Create(ctx context.Context, req resource.Crea ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "region", region) - payload, err := toCreatePayload(ctx, &model) + payload, err := toCreatePayload(&model) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating volume automation", fmt.Sprintf("Creating API payload: %v", err)) return @@ -402,8 +300,7 @@ func (r *volumeAutomationResource) Read(ctx context.Context, req resource.ReadRe automationResp, err := r.client.DefaultAPI.GetVolumeAutomation(ctx, projectId, region, automationId).Execute() if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) return } @@ -453,13 +350,28 @@ func (r *volumeAutomationResource) Update(ctx context.Context, req resource.Upda ctx = tflog.SetField(ctx, "automation_id", automationId) ctx = tflog.SetField(ctx, "region", region) - payload, err := toUpdatePayload(ctx, &plan) + payload, err := toUpdatePayload(&plan) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume automation", fmt.Sprintf("Creating API payload: %v", err)) return } + // Workaround: The input field is an open object where we don't know all keys. If some input fields where removed, + // we can't set them here to null. For this reason we do one update with updateMask "input", to overwrite the whole input object. + // Setting it to '*' would cause issue when the API gets new fields in the future and is therefore no option. automationResp, err := r.client.DefaultAPI.PartialUpdateVolumeAutomation(ctx, projectId, region, automationId). + PartialUpdateVolumeAutomationPayload(*payload). + UpdateMask("input"). + Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating volume automation", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + // Workaround: Updates all other fields accordingly, which were not already update with the previous update. + automationResp, err = r.client.DefaultAPI.PartialUpdateVolumeAutomation(ctx, projectId, region, automationId). PartialUpdateVolumeAutomationPayload(*payload). Execute() if err != nil { @@ -503,8 +415,7 @@ func (r *volumeAutomationResource) Delete(ctx context.Context, req resource.Dele err := r.client.DefaultAPI.DeleteVolumeAutomation(ctx, projectId, region, automationId).Execute() if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { return } core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting volume automation", fmt.Sprintf("Calling API: %v", err)) @@ -538,7 +449,7 @@ func (r *volumeAutomationResource) ImportState(ctx context.Context, req resource } // mapFields maps a VolumeAutomation API response to the model. -func mapFields(ctx context.Context, apiResp *automation.VolumeAutomation, model *Model, region string) error { +func mapFields(_ context.Context, apiResp *automation.VolumeAutomation, model *Model, region string) error { if apiResp == nil { return fmt.Errorf("response input is nil") } @@ -554,180 +465,105 @@ func mapFields(ctx context.Context, apiResp *automation.VolumeAutomation, model model.TemplateId = types.StringValue(*apiResp.TemplateId) } - model.Name = conversion.StringPointerValueNullIfEmpty(apiResp.Name) - model.Description = conversion.StringPointerValueNullIfEmpty(apiResp.Description) + model.Name = types.StringPointerValue(apiResp.Name) + model.Description = types.StringPointerValue(apiResp.Description) - input, err := mapInput(ctx, apiResp.Input, model.Input) - if err != nil { - return fmt.Errorf("mapping input: %w", err) + var inputString *string + if apiResp.Input.Get() != nil { + inputJson, err := apiResp.Input.MarshalJSON() + if err != nil { + return fmt.Errorf("error marshaling input field: %v", err) + } + inputString = new(string(inputJson)) } - model.Input = input + model.Input = jsontypes.NewNormalizedPointerValue(inputString) model.Triggers = mapTriggers(apiResp.Triggers) return nil } -func mapInput(ctx context.Context, apiInput *automation.VolumeAutomationInput, currentInput *inputModel) (*inputModel, error) { - if apiInput == nil || apiInput.VolumeRecoveryPointManagementInput == nil { - return nil, nil - } - vrpm := apiInput.VolumeRecoveryPointManagementInput - - currentLabels := types.MapNull(types.StringType) - if currentInput != nil && currentInput.VolumeRecoveryPointManagement != nil { - currentLabels = currentInput.VolumeRecoveryPointManagement.RecoveryPointLabels - } - - labels, err := utils.MapLabels(ctx, vrpm.RecoveryPointLabels, currentLabels) - if err != nil { - return nil, fmt.Errorf("mapping recovery point labels: %w", err) - } - - srp, err := mapSnapshotRetentionPolicy(vrpm.SnapshotRetentionPolicy) - if err != nil { - return nil, err - } - - return &inputModel{ - VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - InheritVolumeLabels: types.BoolPointerValue(vrpm.InheritVolumeLabels), - RecoveryPointLabels: labels, - VolumeLabelSelector: conversion.StringPointerValueNullIfEmpty(vrpm.VolumeLabelSelector), - SnapshotRetentionPolicy: srp, - }, - }, nil -} - -func mapSnapshotRetentionPolicy(srp automation.SnapshotRetentionPolicy) (*snapshotRetentionPolicyModel, error) { - switch { - case srp.SnapshotRetentionPolicyCount != nil: - return &snapshotRetentionPolicyModel{ - Kind: types.StringValue(string(srp.SnapshotRetentionPolicyCount.Kind)), - Value: types.Int32Value(srp.SnapshotRetentionPolicyCount.Value), - }, nil - case srp.SnapshotRetentionPolicyIndefinitely != nil: - return &snapshotRetentionPolicyModel{ - Kind: types.StringValue(string(srp.SnapshotRetentionPolicyIndefinitely.Kind)), - Value: types.Int32Null(), - }, nil - default: - return nil, fmt.Errorf("response contains an unknown snapshot retention policy variant") - } -} - -func mapTriggers(triggers *automation.AutomationTriggers) *triggersModel { - if triggers == nil || triggers.Schedule == nil { +func mapTriggers(triggers automation.NullableAutomationTriggers) *triggersModel { + if triggers.Get() == nil { return nil } + if triggers.Get().Schedule.Get() == nil { + return &triggersModel{ + Schedule: nil, + } + } return &triggersModel{ Schedule: &scheduleTriggerModel{ - Rrule: types.StringValue(triggers.Schedule.Rrule), + Rrule: types.StringValue(triggers.Get().Schedule.Get().Rrule), }, } } -func toCreatePayload(ctx context.Context, model *Model) (*automation.CreateVolumeAutomationPayload, error) { +func toCreatePayload(model *Model) (*automation.CreateVolumeAutomationPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } - input, err := toInputPayload(ctx, model.Input) + input, err := toInputPayload(model.Input) if err != nil { return nil, err } return &automation.CreateVolumeAutomationPayload{ TemplateId: model.TemplateId.ValueString(), - Name: conversion.StringValueToPointer(model.Name), - Description: conversion.StringValueToPointer(model.Description), - Input: input, - Triggers: toTriggersPayload(model.Triggers), + Name: *automation.NewNullableString(conversion.StringValueToPointer(model.Name)), + Description: *automation.NewNullableString(conversion.StringValueToPointer(model.Description)), + Input: *input, + Triggers: *toTriggersPayload(model), }, nil } -func toUpdatePayload(ctx context.Context, plan *Model) (*automation.PartialUpdateVolumeAutomationPayload, error) { +func toUpdatePayload(plan *Model) (*automation.PartialUpdateVolumeAutomationPayload, error) { if plan == nil { return nil, fmt.Errorf("nil plan model") } - input, err := toInputPayload(ctx, plan.Input) + input, err := toInputPayload(plan.Input) if err != nil { return nil, err } return &automation.PartialUpdateVolumeAutomationPayload{ - // sent as explicit "" instead of omitted so clearing them actually takes effect - Name: new(plan.Name.ValueString()), - Description: new(plan.Description.ValueString()), - Input: input, - Triggers: toTriggersPayload(plan.Triggers), + Name: *automation.NewNullableString(conversion.StringValueToPointer(plan.Name)), + Description: *automation.NewNullableString(conversion.StringValueToPointer(plan.Description)), + Input: *input, + Triggers: *toTriggersPayload(plan), }, nil } -func toInputPayload(ctx context.Context, model *inputModel) (*automation.VolumeAutomationInput, error) { - if model == nil || model.VolumeRecoveryPointManagement == nil { - return nil, nil +func toInputPayload(modelInput jsontypes.Normalized) (*automation.NullableVolumeAutomationInput, error) { + if utils.IsUndefined(modelInput) { + return automation.NewNullableVolumeAutomationInput(nil), nil } - vrpm := model.VolumeRecoveryPointManagement - srp, err := toSnapshotRetentionPolicyPayload(vrpm.SnapshotRetentionPolicy) - if err != nil { - return nil, err - } + inputJson := modelInput.ValueString() + inputPayload := &automation.NullableVolumeAutomationInput{} - var recoveryPointLabels *map[string]string - if !utils.IsUndefined(vrpm.RecoveryPointLabels) { - labels, err := utils.LabelsToPayload(ctx, vrpm.RecoveryPointLabels) - if err != nil { - return nil, fmt.Errorf("converting recovery_point_labels: %w", err) - } - recoveryPointLabels = &labels + err := json.Unmarshal([]byte(inputJson), inputPayload) + if err != nil { + return nil, fmt.Errorf("unmarshaling input payload: %w", err) } - - input := automation.VolumeRecoveryPointManagementInputAsVolumeAutomationInput(&automation.VolumeRecoveryPointManagementInput{ - Kind: string(automation.VOLUMETEMPLATEAUTOMATIONINPUTKIND_VOLUME_RECOVERY_POINT_MANAGEMENT), - InheritVolumeLabels: conversion.BoolValueToPointer(vrpm.InheritVolumeLabels), - RecoveryPointLabels: recoveryPointLabels, - VolumeLabelSelector: conversion.StringValueToPointer(vrpm.VolumeLabelSelector), - SnapshotRetentionPolicy: srp, - }) - return &input, nil + return inputPayload, nil } -func toSnapshotRetentionPolicyPayload(model *snapshotRetentionPolicyModel) (automation.SnapshotRetentionPolicy, error) { - if model == nil { - return automation.SnapshotRetentionPolicy{}, fmt.Errorf("snapshot_retention_policy is required when volume_recovery_point_management is set") +func toTriggersPayload(model *Model) *automation.NullableAutomationTriggers { + if model == nil || model.Triggers == nil { + return automation.NewNullableAutomationTriggers(nil) } - - switch model.Kind.ValueString() { - case string(automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT): - if utils.IsUndefined(model.Value) { - return automation.SnapshotRetentionPolicy{}, fmt.Errorf("value is required when snapshot_retention_policy kind is %q", automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT) - } - return automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ - Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, - Value: model.Value.ValueInt32(), - }), nil - case string(automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY): - if !utils.IsUndefined(model.Value) { - return automation.SnapshotRetentionPolicy{}, fmt.Errorf("value must not be set when snapshot_retention_policy kind is %q", automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY) - } - return automation.SnapshotRetentionPolicyIndefinitelyAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyIndefinitely{ - Kind: automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY, - }), nil - default: - return automation.SnapshotRetentionPolicy{}, fmt.Errorf("unsupported snapshot_retention_policy kind %q", model.Kind.ValueString()) + if model.Triggers.Schedule == nil || utils.IsUndefined(model.Triggers.Schedule.Rrule) { + return automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(nil), + }) } -} -func toTriggersPayload(model *triggersModel) *automation.AutomationTriggers { - if model == nil || model.Schedule == nil { - return nil - } - return &automation.AutomationTriggers{ - Schedule: &automation.AutomationScheduleTrigger{ - Rrule: model.Schedule.Rrule.ValueString(), - }, - } + return automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(&automation.AutomationScheduleTrigger{ + Rrule: model.Triggers.Schedule.Rrule.ValueString(), + }), + }) } diff --git a/stackit/internal/services/automation/volume/resource_test.go b/stackit/internal/services/automation/volume/resource_test.go index bb460b92c..b9f334185 100644 --- a/stackit/internal/services/automation/volume/resource_test.go +++ b/stackit/internal/services/automation/volume/resource_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/types" automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" @@ -40,129 +40,118 @@ func TestMapFields(t *testing.T) { true, }, { - // API returns "" for unset optional fields, must be mapped to null - "empty_strings_treated_as_null", + "empty_strings_for_name_and_description", &automation.VolumeAutomation{ Id: "automation_uid", Name: new(""), Description: new(""), - Input: &automation.VolumeAutomationInput{ - VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ - Kind: "VolumeRecoveryPointManagement", - VolumeLabelSelector: new(""), - SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyIndefinitelyAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyIndefinitely{ - Kind: automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY, - }), - }, - }, - }, - Model{ - ID: types.StringValue("project_uid,eu01,automation_uid"), - ProjectId: types.StringValue("project_uid"), - Region: types.StringValue("eu01"), - AutomationId: types.StringValue("automation_uid"), - Name: types.StringNull(), - Description: types.StringNull(), - Input: &inputModel{ - VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - InheritVolumeLabels: types.BoolNull(), - RecoveryPointLabels: types.MapNull(types.StringType), - VolumeLabelSelector: types.StringNull(), - SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - Kind: types.StringValue("indefinitely"), - Value: types.Int32Null(), - }, - }, - }, - }, - true, - }, - { - "full_values_count", - &automation.VolumeAutomation{ - Id: "automation_uid", - TemplateId: new("template_uid"), - Name: new("name1"), - Description: new("desc1"), - Input: &automation.VolumeAutomationInput{ - VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ - Kind: "VolumeRecoveryPointManagement", - InheritVolumeLabels: new(true), - RecoveryPointLabels: &map[string]string{"k": "v"}, - VolumeLabelSelector: new("sel"), - SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ - Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, - Value: 7, - }), - }, - }, - Triggers: &automation.AutomationTriggers{ - Schedule: &automation.AutomationScheduleTrigger{ - Rrule: "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1", - }, - }, - }, - Model{ - ID: types.StringValue("project_uid,eu01,automation_uid"), - ProjectId: types.StringValue("project_uid"), - Region: types.StringValue("eu01"), - AutomationId: types.StringValue("automation_uid"), - TemplateId: types.StringValue("template_uid"), - Name: types.StringValue("name1"), - Description: types.StringValue("desc1"), - Input: &inputModel{ - VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - InheritVolumeLabels: types.BoolValue(true), - RecoveryPointLabels: types.MapValueMust(types.StringType, map[string]attr.Value{"k": types.StringValue("v")}), - VolumeLabelSelector: types.StringValue("sel"), - SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - Kind: types.StringValue("count"), - Value: types.Int32Value(7), + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagementInput", + AdditionalProperties: map[string]interface{}{ + "volumeLabelSelector": "", + "snapshotRetentionPolicy": map[string]interface{}{ + "kind": "indefinitely", }, }, - }, - Triggers: &triggersModel{ - Schedule: &scheduleTriggerModel{ - Rrule: types.StringValue("DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"), - }, - }, - }, - true, - }, - { - "full_values_indefinitely", - &automation.VolumeAutomation{ - Id: "automation_uid", - Input: &automation.VolumeAutomationInput{ - VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ - Kind: "VolumeRecoveryPointManagement", - SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyIndefinitelyAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyIndefinitely{ - Kind: automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY, - }), - }, - }, + }), }, Model{ ID: types.StringValue("project_uid,eu01,automation_uid"), ProjectId: types.StringValue("project_uid"), Region: types.StringValue("eu01"), AutomationId: types.StringValue("automation_uid"), - Name: types.StringNull(), - Description: types.StringNull(), - Input: &inputModel{ - VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - InheritVolumeLabels: types.BoolNull(), - RecoveryPointLabels: types.MapNull(types.StringType), - VolumeLabelSelector: types.StringNull(), - SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - Kind: types.StringValue("indefinitely"), - Value: types.Int32Null(), - }, - }, - }, + Name: types.StringValue(""), + Description: types.StringValue(""), + Input: jsontypes.NewNormalizedValue("{\"kind\":\"VolumeRecoveryPointManagementInput\",\"snapshotRetentionPolicy\":{\"kind\":\"indefinitely\"},\"volumeLabelSelector\":\"\"}"), }, true, }, + //{ + // "full_values_count", + // &automation.VolumeAutomation{ + // Id: "automation_uid", + // TemplateId: new("template_uid"), + // Name: new("name1"), + // Description: new("desc1"), + // Input: &automation.VolumeAutomationInput{ + // VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ + // Kind: "VolumeRecoveryPointManagement", + // InheritVolumeLabels: new(true), + // RecoveryPointLabels: &map[string]string{"k": "v"}, + // VolumeLabelSelector: new("sel"), + // SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ + // Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, + // Value: 7, + // }), + // }, + // }, + // Triggers: &automation.AutomationTriggers{ + // Schedule: &automation.AutomationScheduleTrigger{ + // Rrule: "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1", + // }, + // }, + // }, + // Model{ + // ID: types.StringValue("project_uid,eu01,automation_uid"), + // ProjectId: types.StringValue("project_uid"), + // Region: types.StringValue("eu01"), + // AutomationId: types.StringValue("automation_uid"), + // TemplateId: types.StringValue("template_uid"), + // Name: types.StringValue("name1"), + // Description: types.StringValue("desc1"), + // Input: &inputModel{ + // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + // InheritVolumeLabels: types.BoolValue(true), + // RecoveryPointLabels: types.MapValueMust(types.StringType, map[string]attr.Value{"k": types.StringValue("v")}), + // VolumeLabelSelector: types.StringValue("sel"), + // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + // Kind: types.StringValue("count"), + // Value: types.Int32Value(7), + // }, + // }, + // }, + // Triggers: &triggersModel{ + // Schedule: &scheduleTriggerModel{ + // Rrule: types.StringValue("DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"), + // }, + // }, + // }, + // true, + //}, + //{ + // "full_values_indefinitely", + // &automation.VolumeAutomation{ + // Id: "automation_uid", + // Input: &automation.VolumeAutomationInput{ + // VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ + // Kind: "VolumeRecoveryPointManagement", + // SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyIndefinitelyAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyIndefinitely{ + // Kind: automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY, + // }), + // }, + // }, + // }, + // Model{ + // ID: types.StringValue("project_uid,eu01,automation_uid"), + // ProjectId: types.StringValue("project_uid"), + // Region: types.StringValue("eu01"), + // AutomationId: types.StringValue("automation_uid"), + // Name: types.StringNull(), + // Description: types.StringNull(), + // Input: &inputModel{ + // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + // InheritVolumeLabels: types.BoolNull(), + // RecoveryPointLabels: types.MapNull(types.StringType), + // VolumeLabelSelector: types.StringNull(), + // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + // Kind: types.StringValue("indefinitely"), + // Value: types.Int32Null(), + // }, + // }, + // }, + // }, + // true, + //}, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { @@ -207,87 +196,88 @@ func TestToCreatePayload(t *testing.T) { }, &automation.CreateVolumeAutomationPayload{ TemplateId: "template_uid", + Input: *automation.NewNullableVolumeAutomationInput(nil), + Triggers: *automation.NewNullableAutomationTriggers(nil), }, true, }, - { - "full_values_count", - &Model{ - TemplateId: types.StringValue("template_uid"), - Name: types.StringValue("name1"), - Description: types.StringValue("desc1"), - Input: &inputModel{ - VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - InheritVolumeLabels: types.BoolValue(true), - RecoveryPointLabels: types.MapValueMust(types.StringType, map[string]attr.Value{"k": types.StringValue("v")}), - VolumeLabelSelector: types.StringValue("sel"), - SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - Kind: types.StringValue("count"), - Value: types.Int32Value(7), - }, - }, - }, - Triggers: &triggersModel{ - Schedule: &scheduleTriggerModel{ - Rrule: types.StringValue("RRULE"), - }, - }, - }, - &automation.CreateVolumeAutomationPayload{ - TemplateId: "template_uid", - Name: new("name1"), - Description: new("desc1"), - Input: &automation.VolumeAutomationInput{ - VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ - Kind: "VolumeRecoveryPointManagement", - InheritVolumeLabels: new(true), - RecoveryPointLabels: &map[string]string{"k": "v"}, - VolumeLabelSelector: new("sel"), - SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ - Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, - Value: 7, - }), - }, - }, - Triggers: &automation.AutomationTriggers{ - Schedule: &automation.AutomationScheduleTrigger{ - Rrule: "RRULE", - }, - }, - }, - true, - }, - { - "count_kind_missing_value", - &Model{ - TemplateId: types.StringValue("template_uid"), - Input: &inputModel{ - VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - Kind: types.StringValue("count"), - }, - }, - }, - }, - nil, - false, - }, - { - "missing_snapshot_retention_policy", - &Model{ - TemplateId: types.StringValue("template_uid"), - Input: &inputModel{ - VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{}, - }, - }, - nil, - false, - }, + //{ + // "full_values_count", + // &Model{ + // TemplateId: types.StringValue("template_uid"), + // Name: types.StringValue("name1"), + // Description: types.StringValue("desc1"), + // Input: &inputModel{ + // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + // InheritVolumeLabels: types.BoolValue(true), + // RecoveryPointLabels: types.MapValueMust(types.StringType, map[string]attr.Value{"k": types.StringValue("v")}), + // VolumeLabelSelector: types.StringValue("sel"), + // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + // Kind: types.StringValue("count"), + // Value: types.Int32Value(7), + // }, + // }, + // }, + // Triggers: &triggersModel{ + // Schedule: &scheduleTriggerModel{ + // Rrule: types.StringValue("RRULE"), + // }, + // }, + // }, + // &automation.CreateVolumeAutomationPayload{ + // TemplateId: "template_uid", + // Name: new("name1"), + // Description: new("desc1"), + // Input: &automation.VolumeAutomationInput{ + // VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ + // Kind: "VolumeRecoveryPointManagement", + // InheritVolumeLabels: new(true), + // RecoveryPointLabels: &map[string]string{"k": "v"}, + // VolumeLabelSelector: new("sel"), + // SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ + // Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, + // Value: 7, + // }), + // }, + // }, + // Triggers: &automation.AutomationTriggers{ + // Schedule: &automation.AutomationScheduleTrigger{ + // Rrule: "RRULE", + // }, + // }, + // }, + // true, + //}, + //{ + // "count_kind_missing_value", + // &Model{ + // TemplateId: types.StringValue("template_uid"), + // Input: &inputModel{ + // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + // Kind: types.StringValue("count"), + // }, + // }, + // }, + // }, + // nil, + // false, + //}, + //{ + // "missing_snapshot_retention_policy", + // &Model{ + // TemplateId: types.StringValue("template_uid"), + // Input: &inputModel{ + // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{}, + // }, + // }, + // nil, + // false, + //}, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - ctx := context.TODO() - output, err := toCreatePayload(ctx, tt.input) + output, err := toCreatePayload(tt.input) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -295,7 +285,13 @@ func TestToCreatePayload(t *testing.T) { t.Fatalf("Should not have failed: %v", err) } if tt.isValid { - diff := cmp.Diff(output, tt.expected) + cmpOpts := cmp.Options{ + cmp.AllowUnexported( + automation.NullableVolumeAutomationInput{}, + automation.NullableAutomationTriggers{}, + ), + } + diff := cmp.Diff(tt.expected, output, cmpOpts) if diff != "" { t.Fatalf("Data does not match: %s", diff) } @@ -305,14 +301,14 @@ func TestToCreatePayload(t *testing.T) { } func TestToUpdatePayload(t *testing.T) { - baseInput := &inputModel{ - VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - Kind: types.StringValue("count"), - Value: types.Int32Value(3), - }, - }, - } + //baseInput := &inputModel{ + // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ + // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ + // Kind: types.StringValue("count"), + // Value: types.Int32Value(3), + // }, + // }, + //} tests := []struct { description string @@ -326,49 +322,53 @@ func TestToUpdatePayload(t *testing.T) { nil, false, }, + //{ + // "full_values", + // &Model{ + // Name: types.StringValue("n"), + // Description: types.StringValue("d"), + // Input: baseInput, + // Triggers: &triggersModel{ + // Schedule: &scheduleTriggerModel{Rrule: types.StringValue("RRULE")}, + // }, + // }, + // &automation.PartialUpdateVolumeAutomationPayload{ + // Name: new("n"), + // Description: new("d"), + // Input: &automation.VolumeAutomationInput{ + // VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ + // Kind: "VolumeRecoveryPointManagement", + // SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ + // Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, + // Value: 3, + // }), + // }, + // }, + // Triggers: &automation.AutomationTriggers{ + // Schedule: &automation.AutomationScheduleTrigger{Rrule: "RRULE"}, + // }, + // }, + // true, + //}, { - "full_values", + "clears_optional_string_fields", &Model{ - Name: types.StringValue("n"), - Description: types.StringValue("d"), - Input: baseInput, - Triggers: &triggersModel{ - Schedule: &scheduleTriggerModel{Rrule: types.StringValue("RRULE")}, - }, + Name: types.StringNull(), + Description: types.StringNull(), }, &automation.PartialUpdateVolumeAutomationPayload{ - Name: new("n"), - Description: new("d"), - Input: &automation.VolumeAutomationInput{ - VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ - Kind: "VolumeRecoveryPointManagement", - SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ - Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, - Value: 3, - }), - }, - }, - Triggers: &automation.AutomationTriggers{ - Schedule: &automation.AutomationScheduleTrigger{Rrule: "RRULE"}, - }, - }, - true, - }, - { - // clearing must send explicit "" instead of omitting the field - "clears_optional_string_fields", - &Model{Name: types.StringNull(), Description: types.StringNull()}, - &automation.PartialUpdateVolumeAutomationPayload{ + //TODO: should be updated to null as soon the API spec is updated Name: new(""), Description: new(""), + Input: *automation.NewNullableVolumeAutomationInput(nil), + Triggers: *automation.NewNullableAutomationTriggers(nil), }, true, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - ctx := context.TODO() - output, err := toUpdatePayload(ctx, tt.plan) + output, err := toUpdatePayload(tt.plan) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -376,7 +376,13 @@ func TestToUpdatePayload(t *testing.T) { t.Fatalf("Should not have failed: %v", err) } if tt.isValid { - diff := cmp.Diff(output, tt.expected) + cmpOpts := cmp.Options{ + cmp.AllowUnexported( + automation.NullableVolumeAutomationInput{}, + automation.NullableAutomationTriggers{}, + ), + } + diff := cmp.Diff(output, tt.expected, cmpOpts) if diff != "" { t.Fatalf("Data does not match: %s", diff) } From 07c23f59dfba8e12622026fd6f858ffd9093f376 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Mon, 21 Sep 2026 17:16:37 +0200 Subject: [PATCH 3/5] change to v1api --- docs/data-sources/volume_automation.md | 2 +- docs/resources/volume_automation.md | 26 +- .../stackit_volume_automation/resource.tf | 24 +- go.mod | 3 +- go.sum | 6 +- .../automation/automation_acc_test.go | 7 +- .../automation/templates/datasource.go | 2 +- .../automation/templates/datasource_test.go | 2 +- .../services/automation/utils/util.go | 2 +- .../services/automation/utils/util_test.go | 2 +- .../services/automation/volume/datasource.go | 2 +- .../services/automation/volume/resource.go | 10 +- .../automation/volume/resource_test.go | 352 ++++++++---------- 13 files changed, 196 insertions(+), 244 deletions(-) diff --git a/docs/data-sources/volume_automation.md b/docs/data-sources/volume_automation.md index 152f0411b..f10c43124 100644 --- a/docs/data-sources/volume_automation.md +++ b/docs/data-sources/volume_automation.md @@ -38,7 +38,7 @@ data "stackit_volume_automation" "example" { - `description` (String) The volume automation description. - `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`automation_id`". -- `input` (String) Configuration input for the volume automation. Exactly one of the nested attributes must be set. +- `input` (String) Configuration input for the volume automation. See [API Docs](https://docs.api.stackit.cloud/documentation/automation-service/version/v1#tag/Volume-Automations/operation/CreateVolumeAutomation) for possible configuration options. - `name` (String) The volume automation name. - `template_id` (String) ID of the automation template this volume automation is based on. - `triggers` (Attributes) Triggers that determine when the automation runs. (see [below for nested schema](#nestedatt--triggers)) diff --git a/docs/resources/volume_automation.md b/docs/resources/volume_automation.md index 2df080efb..262d09b23 100644 --- a/docs/resources/volume_automation.md +++ b/docs/resources/volume_automation.md @@ -21,19 +21,19 @@ resource "stackit_volume_automation" "example" { template_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "example-volume-automation" description = "Creates daily volume snapshots and keeps the last 7." - input = { - volume_recovery_point_management = { - inherit_volume_labels = true - recovery_point_labels = { - "created-by" = "terraform" - } - volume_label_selector = "backup=daily" - snapshot_retention_policy = { - kind = "count" - value = 4 - } + input = jsonencode({ + kind = "VolumeRecoveryPointManagement" + inheritVolumeLabels = false + recoveryPointLabels = { + "exampleLabelKey1" = "exampleLabelValue1" + "exampleLabelKey2" = "exampleLabelValue2" } - } + snapshotRetentionPolicy = { + kind = "count" + value = 2 + } + volumeLabelSelector = "myLabelkey1=myLabelValue,myLabelKey2=myOtherLabelValue" + }) triggers = { schedule = { rrule = "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1" @@ -53,7 +53,7 @@ resource "stackit_volume_automation" "example" { ### Optional - `description` (String) The volume automation description. -- `input` (String) Configuration input for the volume automation. Exactly one of the nested attributes must be set. +- `input` (String) Configuration input for the volume automation. See [API Docs](https://docs.api.stackit.cloud/documentation/automation-service/version/v1#tag/Volume-Automations/operation/CreateVolumeAutomation) for possible configuration options. - `name` (String) The volume automation name. - `region` (String) The resource region. If not defined, the provider region is used. - `triggers` (Attributes) Triggers that determine when the automation runs. (see [below for nested schema](#nestedatt--triggers)) diff --git a/examples/resources/stackit_volume_automation/resource.tf b/examples/resources/stackit_volume_automation/resource.tf index 6a6a344e3..8d541fa21 100644 --- a/examples/resources/stackit_volume_automation/resource.tf +++ b/examples/resources/stackit_volume_automation/resource.tf @@ -3,19 +3,19 @@ resource "stackit_volume_automation" "example" { template_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" name = "example-volume-automation" description = "Creates daily volume snapshots and keeps the last 7." - input = { - volume_recovery_point_management = { - inherit_volume_labels = true - recovery_point_labels = { - "created-by" = "terraform" - } - volume_label_selector = "backup=daily" - snapshot_retention_policy = { - kind = "count" - value = 4 - } + input = jsonencode({ + kind = "VolumeRecoveryPointManagement" + inheritVolumeLabels = false + recoveryPointLabels = { + "exampleLabelKey1" = "exampleLabelValue1" + "exampleLabelKey2" = "exampleLabelValue2" } - } + snapshotRetentionPolicy = { + kind = "count" + value = 2 + } + volumeLabelSelector = "myLabelkey1=myLabelValue,myLabelKey2=myOtherLabelValue" + }) triggers = { schedule = { rrule = "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1" diff --git a/go.mod b/go.mod index e7c95e7d5..1fc26cf6c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/hashicorp/terraform-plugin-framework v1.19.0 + github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 github.com/hashicorp/terraform-plugin-framework-timeouts v0.7.0 github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 github.com/hashicorp/terraform-plugin-go v0.31.0 @@ -15,7 +16,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.27.0 github.com/stackitcloud/stackit-sdk-go/services/alb v0.17.2 github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.4 - github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.1 + github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.2-0.20260921140418-216536addbae //TODO: update to released v0.2.0 before merging github.com/stackitcloud/stackit-sdk-go/services/cdn v1.21.0 github.com/stackitcloud/stackit-sdk-go/services/certificates v1.9.2 github.com/stackitcloud/stackit-sdk-go/services/dns v0.23.1 diff --git a/go.sum b/go.sum index d49bec7aa..2b4af8b03 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ github.com/hashicorp/terraform-json v0.28.0 h1:dOkJT55rWfU6T1/VklHde51ym4LfNP+9x github.com/hashicorp/terraform-json v0.28.0/go.mod h1:PJIRf+Yzu5iLb52c/xYp1tUOL4jzMzfIAB5gvWWKIWE= github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 h1:SJXL5FfJJm17554Kpt9jFXngdM6fXbnUnZ6iT2IeiYA= +github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0/go.mod h1:p0phD0IYhsu9bR4+6OetVvvH59I6LwjXGnTVEr8ox6E= github.com/hashicorp/terraform-plugin-framework-timeouts v0.7.0 h1:jblRy1PkLfPm5hb5XeMa3tezusnMRziUGqtT5epSYoI= github.com/hashicorp/terraform-plugin-framework-timeouts v0.7.0/go.mod h1:5jm2XK8uqrdiSRfD5O47OoxyGMCnwTcl8eoiDgSa+tc= github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow= @@ -161,8 +163,8 @@ github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.4 h1:KNEcAPj66Asb00 github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.4/go.mod h1:riXgEDfIjNdFjYk81zNnWt0npkv1+/P1iFRfFIt28Jo= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.4 h1:YRfEQJxMn9B/yCf5ZGTsBzPLv69LM/jvvLuC8vb0Uf4= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.4/go.mod h1:AVLUwOPKsoz6HvKzClu0a5OTjMOYS696gRjHUQvQH70= -github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.1 h1:naKG2lRw9ALCNaaJabhgtuCwSBVyOOlIX3NPkIFX1i0= -github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.1/go.mod h1:1vv2SZorcHJKxEPV15CnTDhavV+NcYSNjAX7cZGX31k= +github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.2-0.20260921140418-216536addbae h1:QRkhohGahXpHBx9CrrI18vxo9KV4P9QHlNyNcGk7euM= +github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.2-0.20260921140418-216536addbae/go.mod h1:1vv2SZorcHJKxEPV15CnTDhavV+NcYSNjAX7cZGX31k= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.21.0 h1:lCs1eq/OOTdwGrrkxkJ8qCnS6yLM4XZhznpML3nd1NI= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.21.0/go.mod h1:NBgvZH5ekJ2hFCNI480C9MdJsa4989YGC1YgX5ELA48= github.com/stackitcloud/stackit-sdk-go/services/certificates v1.9.2 h1:QEKzHzVwZ80mNdoEWEZxgynACnlPfXwIlVz9b7xceR4= diff --git a/stackit/internal/services/automation/automation_acc_test.go b/stackit/internal/services/automation/automation_acc_test.go index 1a2686e24..a03ff1755 100644 --- a/stackit/internal/services/automation/automation_acc_test.go +++ b/stackit/internal/services/automation/automation_acc_test.go @@ -17,7 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/terraform" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" ) @@ -43,6 +43,11 @@ var ( // template of kind "VolumeRecoveryPointManagement". Volume automation templates are not // created via Terraform, so acceptance tests have to discover a valid template_id at runtime. func lookupVolumeRecoveryPointManagementTemplateID(t *testing.T) string { + // skip if TF_ACC=1 is not set + if !testutil.E2ETestsEnabled { + return "" + } + t.Helper() ctx := context.Background() diff --git a/stackit/internal/services/automation/templates/datasource.go b/stackit/internal/services/automation/templates/datasource.go index 68f58ef1e..4771c01bb 100644 --- a/stackit/internal/services/automation/templates/datasource.go +++ b/stackit/internal/services/automation/templates/datasource.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/experimental/paginate" - automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" diff --git a/stackit/internal/services/automation/templates/datasource_test.go b/stackit/internal/services/automation/templates/datasource_test.go index 4928ec6fe..59b49f814 100644 --- a/stackit/internal/services/automation/templates/datasource_test.go +++ b/stackit/internal/services/automation/templates/datasource_test.go @@ -6,7 +6,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/types" - automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" ) var testTimestampValue = "2006-01-02T15:04:05Z" diff --git a/stackit/internal/services/automation/utils/util.go b/stackit/internal/services/automation/utils/util.go index aca66c6df..96f9392b4 100644 --- a/stackit/internal/services/automation/utils/util.go +++ b/stackit/internal/services/automation/utils/util.go @@ -10,7 +10,7 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/stackit-sdk-go/core/config" - automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" ) func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *automation.APIClient { diff --git a/stackit/internal/services/automation/utils/util_test.go b/stackit/internal/services/automation/utils/util_test.go index 8dc2a490d..70cf1f487 100644 --- a/stackit/internal/services/automation/utils/util_test.go +++ b/stackit/internal/services/automation/utils/util_test.go @@ -10,7 +10,7 @@ import ( sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients" "github.com/stackitcloud/stackit-sdk-go/core/config" - automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" diff --git a/stackit/internal/services/automation/volume/datasource.go b/stackit/internal/services/automation/volume/datasource.go index 1ab8aab37..4df27ff92 100644 --- a/stackit/internal/services/automation/volume/datasource.go +++ b/stackit/internal/services/automation/volume/datasource.go @@ -11,7 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-log/tflog" - automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" diff --git a/stackit/internal/services/automation/volume/resource.go b/stackit/internal/services/automation/volume/resource.go index c82d30c83..e01c67f2b 100644 --- a/stackit/internal/services/automation/volume/resource.go +++ b/stackit/internal/services/automation/volume/resource.go @@ -18,7 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" @@ -66,7 +66,7 @@ var descriptions = map[string]string{ "automation_id": "ID of the volume automation.", "name": "The volume automation name.", "description": "The volume automation description.", - "input": "Configuration input for the volume automation. Exactly one of the nested attributes must be set.", // TODO: update description + "input": "Configuration input for the volume automation. See [API Docs](https://docs.api.stackit.cloud/documentation/automation-service/version/v1#tag/Volume-Automations/operation/CreateVolumeAutomation) for possible configuration options.", "volume_recovery_point_management": "Configuration for automated volume recovery point (snapshot) management.", "inherit_volume_labels": "Whether recovery points inherit the labels of the volume they were created from. Defaults to `false`.", "recovery_point_labels": "Labels to attach to created recovery points.", @@ -359,7 +359,7 @@ func (r *volumeAutomationResource) Update(ctx context.Context, req resource.Upda // Workaround: The input field is an open object where we don't know all keys. If some input fields where removed, // we can't set them here to null. For this reason we do one update with updateMask "input", to overwrite the whole input object. // Setting it to '*' would cause issue when the API gets new fields in the future and is therefore no option. - automationResp, err := r.client.DefaultAPI.PartialUpdateVolumeAutomation(ctx, projectId, region, automationId). + _, err = r.client.DefaultAPI.PartialUpdateVolumeAutomation(ctx, projectId, region, automationId). PartialUpdateVolumeAutomationPayload(*payload). UpdateMask("input"). Execute() @@ -371,7 +371,7 @@ func (r *volumeAutomationResource) Update(ctx context.Context, req resource.Upda ctx = core.LogResponse(ctx) // Workaround: Updates all other fields accordingly, which were not already update with the previous update. - automationResp, err = r.client.DefaultAPI.PartialUpdateVolumeAutomation(ctx, projectId, region, automationId). + automationResp, err := r.client.DefaultAPI.PartialUpdateVolumeAutomation(ctx, projectId, region, automationId). PartialUpdateVolumeAutomationPayload(*payload). Execute() if err != nil { @@ -472,7 +472,7 @@ func mapFields(_ context.Context, apiResp *automation.VolumeAutomation, model *M if apiResp.Input.Get() != nil { inputJson, err := apiResp.Input.MarshalJSON() if err != nil { - return fmt.Errorf("error marshaling input field: %v", err) + return fmt.Errorf("error marshaling input field: %w", err) } inputString = new(string(inputJson)) } diff --git a/stackit/internal/services/automation/volume/resource_test.go b/stackit/internal/services/automation/volume/resource_test.go index b9f334185..c36a47a10 100644 --- a/stackit/internal/services/automation/volume/resource_test.go +++ b/stackit/internal/services/automation/volume/resource_test.go @@ -8,7 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/types" - automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1betaapi" + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" ) func TestMapFields(t *testing.T) { @@ -66,92 +66,74 @@ func TestMapFields(t *testing.T) { }, true, }, - //{ - // "full_values_count", - // &automation.VolumeAutomation{ - // Id: "automation_uid", - // TemplateId: new("template_uid"), - // Name: new("name1"), - // Description: new("desc1"), - // Input: &automation.VolumeAutomationInput{ - // VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ - // Kind: "VolumeRecoveryPointManagement", - // InheritVolumeLabels: new(true), - // RecoveryPointLabels: &map[string]string{"k": "v"}, - // VolumeLabelSelector: new("sel"), - // SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ - // Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, - // Value: 7, - // }), - // }, - // }, - // Triggers: &automation.AutomationTriggers{ - // Schedule: &automation.AutomationScheduleTrigger{ - // Rrule: "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1", - // }, - // }, - // }, - // Model{ - // ID: types.StringValue("project_uid,eu01,automation_uid"), - // ProjectId: types.StringValue("project_uid"), - // Region: types.StringValue("eu01"), - // AutomationId: types.StringValue("automation_uid"), - // TemplateId: types.StringValue("template_uid"), - // Name: types.StringValue("name1"), - // Description: types.StringValue("desc1"), - // Input: &inputModel{ - // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - // InheritVolumeLabels: types.BoolValue(true), - // RecoveryPointLabels: types.MapValueMust(types.StringType, map[string]attr.Value{"k": types.StringValue("v")}), - // VolumeLabelSelector: types.StringValue("sel"), - // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - // Kind: types.StringValue("count"), - // Value: types.Int32Value(7), - // }, - // }, - // }, - // Triggers: &triggersModel{ - // Schedule: &scheduleTriggerModel{ - // Rrule: types.StringValue("DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"), - // }, - // }, - // }, - // true, - //}, - //{ - // "full_values_indefinitely", - // &automation.VolumeAutomation{ - // Id: "automation_uid", - // Input: &automation.VolumeAutomationInput{ - // VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ - // Kind: "VolumeRecoveryPointManagement", - // SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyIndefinitelyAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyIndefinitely{ - // Kind: automation.SNAPSHOTRETENTIONPOLICYINDEFINITELYKIND_INDEFINITELY, - // }), - // }, - // }, - // }, - // Model{ - // ID: types.StringValue("project_uid,eu01,automation_uid"), - // ProjectId: types.StringValue("project_uid"), - // Region: types.StringValue("eu01"), - // AutomationId: types.StringValue("automation_uid"), - // Name: types.StringNull(), - // Description: types.StringNull(), - // Input: &inputModel{ - // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - // InheritVolumeLabels: types.BoolNull(), - // RecoveryPointLabels: types.MapNull(types.StringType), - // VolumeLabelSelector: types.StringNull(), - // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - // Kind: types.StringValue("indefinitely"), - // Value: types.Int32Null(), - // }, - // }, - // }, - // }, - // true, - //}, + { + "full_values_count", + &automation.VolumeAutomation{ + Id: "automation_uid", + TemplateId: new("template_uid"), + Name: new("name1"), + Description: new("desc1"), + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + AdditionalProperties: map[string]interface{}{ + "inheritVolumeLabels": true, + "recoveryPointLabels": map[string]string{ + "k": "v", + }, + "snapshotRetentionPolicy": map[string]interface{}{ + "kind": "count", + "value": 7, + }, + "volumeLabelSelector": "sel", + }, + }), + Triggers: *automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(&automation.AutomationScheduleTrigger{ + Rrule: "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1", + }), + }), + }, + Model{ + ID: types.StringValue("project_uid,eu01,automation_uid"), + ProjectId: types.StringValue("project_uid"), + Region: types.StringValue("eu01"), + AutomationId: types.StringValue("automation_uid"), + TemplateId: types.StringValue("template_uid"), + Name: types.StringValue("name1"), + Description: types.StringValue("desc1"), + Input: jsontypes.NewNormalizedValue("{\"inheritVolumeLabels\":true,\"kind\":\"VolumeRecoveryPointManagement\",\"recoveryPointLabels\":{\"k\":\"v\"},\"snapshotRetentionPolicy\":{\"kind\":\"count\",\"value\":7},\"volumeLabelSelector\":\"sel\"}"), + Triggers: &triggersModel{ + Schedule: &scheduleTriggerModel{ + Rrule: types.StringValue("DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"), + }, + }, + }, + true, + }, + { + "full_values_indefinitely", + &automation.VolumeAutomation{ + Id: "automation_uid", + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + AdditionalProperties: map[string]interface{}{ + "snapshotRetentionPolicy": map[string]interface{}{ + "kind": "indefinitely", + }, + }, + }), + }, + Model{ + ID: types.StringValue("project_uid,eu01,automation_uid"), + ProjectId: types.StringValue("project_uid"), + Region: types.StringValue("eu01"), + AutomationId: types.StringValue("automation_uid"), + Name: types.StringNull(), + Description: types.StringNull(), + Input: jsontypes.NewNormalizedValue("{\"kind\":\"VolumeRecoveryPointManagement\",\"snapshotRetentionPolicy\":{\"kind\":\"indefinitely\"}}"), + }, + true, + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { @@ -195,85 +177,53 @@ func TestToCreatePayload(t *testing.T) { TemplateId: types.StringValue("template_uid"), }, &automation.CreateVolumeAutomationPayload{ - TemplateId: "template_uid", - Input: *automation.NewNullableVolumeAutomationInput(nil), - Triggers: *automation.NewNullableAutomationTriggers(nil), + TemplateId: "template_uid", + Name: *automation.NewNullableString(nil), + Description: *automation.NewNullableString(nil), + Input: *automation.NewNullableVolumeAutomationInput(nil), + Triggers: *automation.NewNullableAutomationTriggers(nil), + }, + true, + }, + { + "full_values_count", + &Model{ + TemplateId: types.StringValue("template_uid"), + Name: types.StringValue("name1"), + Description: types.StringValue("desc1"), + Input: jsontypes.NewNormalizedValue("{\"inheritVolumeLabels\":true,\"kind\":\"VolumeRecoveryPointManagement\",\"recoveryPointLabels\":{\"k\":\"v\"},\"snapshotRetentionPolicy\":{\"kind\":\"count\",\"value\":7},\"volumeLabelSelector\":\"sel\"}"), + Triggers: &triggersModel{ + Schedule: &scheduleTriggerModel{ + Rrule: types.StringValue("RRULE"), + }, + }, + }, + &automation.CreateVolumeAutomationPayload{ + TemplateId: "template_uid", + Name: *automation.NewNullableString(new("name1")), + Description: *automation.NewNullableString(new("desc1")), + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + AdditionalProperties: map[string]interface{}{ + "inheritVolumeLabels": true, + "recoveryPointLabels": map[string]interface{}{ + "k": "v", + }, + "snapshotRetentionPolicy": map[string]interface{}{ + "kind": "count", + "value": float64(7), + }, + "volumeLabelSelector": "sel", + }, + }), + Triggers: *automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(&automation.AutomationScheduleTrigger{ + Rrule: "RRULE", + }), + }), }, true, }, - //{ - // "full_values_count", - // &Model{ - // TemplateId: types.StringValue("template_uid"), - // Name: types.StringValue("name1"), - // Description: types.StringValue("desc1"), - // Input: &inputModel{ - // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - // InheritVolumeLabels: types.BoolValue(true), - // RecoveryPointLabels: types.MapValueMust(types.StringType, map[string]attr.Value{"k": types.StringValue("v")}), - // VolumeLabelSelector: types.StringValue("sel"), - // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - // Kind: types.StringValue("count"), - // Value: types.Int32Value(7), - // }, - // }, - // }, - // Triggers: &triggersModel{ - // Schedule: &scheduleTriggerModel{ - // Rrule: types.StringValue("RRULE"), - // }, - // }, - // }, - // &automation.CreateVolumeAutomationPayload{ - // TemplateId: "template_uid", - // Name: new("name1"), - // Description: new("desc1"), - // Input: &automation.VolumeAutomationInput{ - // VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ - // Kind: "VolumeRecoveryPointManagement", - // InheritVolumeLabels: new(true), - // RecoveryPointLabels: &map[string]string{"k": "v"}, - // VolumeLabelSelector: new("sel"), - // SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ - // Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, - // Value: 7, - // }), - // }, - // }, - // Triggers: &automation.AutomationTriggers{ - // Schedule: &automation.AutomationScheduleTrigger{ - // Rrule: "RRULE", - // }, - // }, - // }, - // true, - //}, - //{ - // "count_kind_missing_value", - // &Model{ - // TemplateId: types.StringValue("template_uid"), - // Input: &inputModel{ - // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - // Kind: types.StringValue("count"), - // }, - // }, - // }, - // }, - // nil, - // false, - //}, - //{ - // "missing_snapshot_retention_policy", - // &Model{ - // TemplateId: types.StringValue("template_uid"), - // Input: &inputModel{ - // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{}, - // }, - // }, - // nil, - // false, - //}, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { @@ -289,6 +239,8 @@ func TestToCreatePayload(t *testing.T) { cmp.AllowUnexported( automation.NullableVolumeAutomationInput{}, automation.NullableAutomationTriggers{}, + automation.NullableAutomationScheduleTrigger{}, + automation.NullableString{}, ), } diff := cmp.Diff(tt.expected, output, cmpOpts) @@ -301,15 +253,6 @@ func TestToCreatePayload(t *testing.T) { } func TestToUpdatePayload(t *testing.T) { - //baseInput := &inputModel{ - // VolumeRecoveryPointManagement: &volumeRecoveryPointManagementModel{ - // SnapshotRetentionPolicy: &snapshotRetentionPolicyModel{ - // Kind: types.StringValue("count"), - // Value: types.Int32Value(3), - // }, - // }, - //} - tests := []struct { description string plan *Model @@ -322,44 +265,43 @@ func TestToUpdatePayload(t *testing.T) { nil, false, }, - //{ - // "full_values", - // &Model{ - // Name: types.StringValue("n"), - // Description: types.StringValue("d"), - // Input: baseInput, - // Triggers: &triggersModel{ - // Schedule: &scheduleTriggerModel{Rrule: types.StringValue("RRULE")}, - // }, - // }, - // &automation.PartialUpdateVolumeAutomationPayload{ - // Name: new("n"), - // Description: new("d"), - // Input: &automation.VolumeAutomationInput{ - // VolumeRecoveryPointManagementInput: &automation.VolumeRecoveryPointManagementInput{ - // Kind: "VolumeRecoveryPointManagement", - // SnapshotRetentionPolicy: automation.SnapshotRetentionPolicyCountAsSnapshotRetentionPolicy(&automation.SnapshotRetentionPolicyCount{ - // Kind: automation.SNAPSHOTRETENTIONPOLICYCOUNTKIND_COUNT, - // Value: 3, - // }), - // }, - // }, - // Triggers: &automation.AutomationTriggers{ - // Schedule: &automation.AutomationScheduleTrigger{Rrule: "RRULE"}, - // }, - // }, - // true, - //}, { - "clears_optional_string_fields", + "full_values", + &Model{ + Name: types.StringValue("n"), + Description: types.StringValue("d"), + Input: jsontypes.NewNormalizedValue("{\"kind\":\"VolumeRecoveryPointManagement\",\"snapshotRetentionPolicy\":{\"kind\":\"count\",\"value\":3}}\n"), + Triggers: &triggersModel{ + Schedule: &scheduleTriggerModel{Rrule: types.StringValue("RRULE")}, + }, + }, + &automation.PartialUpdateVolumeAutomationPayload{ + Name: *automation.NewNullableString(new("n")), + Description: *automation.NewNullableString(new("d")), + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagement", + AdditionalProperties: map[string]interface{}{ + "snapshotRetentionPolicy": map[string]interface{}{ + "kind": "count", + "value": float64(3), + }, + }, + }), + Triggers: *automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(&automation.AutomationScheduleTrigger{Rrule: "RRULE"}), + }), + }, + true, + }, + { + "clears_optional_fields", &Model{ Name: types.StringNull(), Description: types.StringNull(), }, &automation.PartialUpdateVolumeAutomationPayload{ - //TODO: should be updated to null as soon the API spec is updated - Name: new(""), - Description: new(""), + Name: *automation.NewNullableString(nil), + Description: *automation.NewNullableString(nil), Input: *automation.NewNullableVolumeAutomationInput(nil), Triggers: *automation.NewNullableAutomationTriggers(nil), }, @@ -380,6 +322,8 @@ func TestToUpdatePayload(t *testing.T) { cmp.AllowUnexported( automation.NullableVolumeAutomationInput{}, automation.NullableAutomationTriggers{}, + automation.NullableAutomationScheduleTrigger{}, + automation.NullableString{}, ), } diff := cmp.Diff(output, tt.expected, cmpOpts) From caca051bd4bb9a2385c321064896f22f693bdd73 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Tue, 22 Sep 2026 13:20:23 +0200 Subject: [PATCH 4/5] update automation module --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1fc26cf6c..172e7a573 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.27.0 github.com/stackitcloud/stackit-sdk-go/services/alb v0.17.2 github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.4 - github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.2-0.20260921140418-216536addbae //TODO: update to released v0.2.0 before merging + github.com/stackitcloud/stackit-sdk-go/services/automation v0.2.0 github.com/stackitcloud/stackit-sdk-go/services/cdn v1.21.0 github.com/stackitcloud/stackit-sdk-go/services/certificates v1.9.2 github.com/stackitcloud/stackit-sdk-go/services/dns v0.23.1 diff --git a/go.sum b/go.sum index 2b4af8b03..c947a141f 100644 --- a/go.sum +++ b/go.sum @@ -163,8 +163,8 @@ github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.4 h1:KNEcAPj66Asb00 github.com/stackitcloud/stackit-sdk-go/services/albwaf v0.13.4/go.mod h1:riXgEDfIjNdFjYk81zNnWt0npkv1+/P1iFRfFIt28Jo= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.4 h1:YRfEQJxMn9B/yCf5ZGTsBzPLv69LM/jvvLuC8vb0Uf4= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.15.4/go.mod h1:AVLUwOPKsoz6HvKzClu0a5OTjMOYS696gRjHUQvQH70= -github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.2-0.20260921140418-216536addbae h1:QRkhohGahXpHBx9CrrI18vxo9KV4P9QHlNyNcGk7euM= -github.com/stackitcloud/stackit-sdk-go/services/automation v0.1.2-0.20260921140418-216536addbae/go.mod h1:1vv2SZorcHJKxEPV15CnTDhavV+NcYSNjAX7cZGX31k= +github.com/stackitcloud/stackit-sdk-go/services/automation v0.2.0 h1:X3tnz8Aij3fRk0vx7Ekvt5ONnP+ACMqwrBuKftHJJdY= +github.com/stackitcloud/stackit-sdk-go/services/automation v0.2.0/go.mod h1:1vv2SZorcHJKxEPV15CnTDhavV+NcYSNjAX7cZGX31k= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.21.0 h1:lCs1eq/OOTdwGrrkxkJ8qCnS6yLM4XZhznpML3nd1NI= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.21.0/go.mod h1:NBgvZH5ekJ2hFCNI480C9MdJsa4989YGC1YgX5ELA48= github.com/stackitcloud/stackit-sdk-go/services/certificates v1.9.2 h1:QEKzHzVwZ80mNdoEWEZxgynACnlPfXwIlVz9b7xceR4= From c863482ec7e78f6af6ee3b7cdfd5cde931cfad65 Mon Sep 17 00:00:00 2001 From: Marcel Jacek Date: Tue, 22 Sep 2026 15:37:48 +0200 Subject: [PATCH 5/5] review feedback --- stackit/internal/services/automation/volume/resource.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/stackit/internal/services/automation/volume/resource.go b/stackit/internal/services/automation/volume/resource.go index e01c67f2b..ddce150a6 100644 --- a/stackit/internal/services/automation/volume/resource.go +++ b/stackit/internal/services/automation/volume/resource.go @@ -461,10 +461,7 @@ func mapFields(_ context.Context, apiResp *automation.VolumeAutomation, model *M model.ID = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, apiResp.Id) model.Region = types.StringValue(region) - if apiResp.TemplateId != nil { - model.TemplateId = types.StringValue(*apiResp.TemplateId) - } - + model.TemplateId = types.StringPointerValue(apiResp.TemplateId) model.Name = types.StringPointerValue(apiResp.Name) model.Description = types.StringPointerValue(apiResp.Description)