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,