diff --git a/docs/data-sources/volume_automation.md b/docs/data-sources/volume_automation.md new file mode 100644 index 000000000..f10c43124 --- /dev/null +++ b/docs/data-sources/volume_automation.md @@ -0,0 +1,58 @@ +--- +# 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` (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)) + + +### 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..262d09b23 --- /dev/null +++ b/docs/resources/volume_automation.md @@ -0,0 +1,92 @@ +--- +# 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 = 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" + } + } +} +``` + + +## 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` (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)) + +### 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 `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..8d541fa21 --- /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 = 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..172e7a573 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.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 d49bec7aa..c947a141f 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.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= diff --git a/stackit/internal/services/automation/automation_acc_test.go b/stackit/internal/services/automation/automation_acc_test.go index 69f299cbd..a03ff1755 100644 --- a/stackit/internal/services/automation/automation_acc_test.go +++ b/stackit/internal/services/automation/automation_acc_test.go @@ -1,17 +1,166 @@ package automation_test import ( + "context" _ "embed" + "encoding/json" + "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/v1api" "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 { + // skip if TF_ACC=1 is not set + if !testutil.E2ETestsEnabled { + return "" + } + + 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 { + // 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)), + "input": config.StringVariable(string(inputJson)), + } +} + +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 { + // 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 +} func TestAccAutomationTemplatesDataSource(t *testing.T) { resource.Test(t, resource.TestCase{ @@ -35,3 +184,209 @@ 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", 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"), + ), + }, + // 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", 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"), + ), + }, + // 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.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 + }, + }) +} + +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", 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"), + ), + }, + // 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", 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"), + ), + }, + // 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", "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", 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 + { + 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) + } + + 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, projectId, region, automationId).Execute() + if err == nil { + errs = append(errs, fmt.Errorf("volume automation %s still exists", automationId)) + continue + } + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + continue + } + errs = append(errs, fmt.Errorf("checking volume automation %s destruction: %w", automationId, err)) + } + return errors.Join(errs...) +} 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/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..b9bcb13c7 --- /dev/null +++ b/stackit/internal/services/automation/testdata/resource-max-cleared.tf @@ -0,0 +1,17 @@ +variable "project_id" {} +variable "region" {} +variable "template_id" {} +variable "rrule" {} +variable "input" {} + +resource "stackit_volume_automation" "test" { + project_id = var.project_id + region = var.region + template_id = var.template_id + 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 new file mode 100644 index 000000000..36bb71bcb --- /dev/null +++ b/stackit/internal/services/automation/testdata/resource-max.tf @@ -0,0 +1,21 @@ +variable "project_id" {} +variable "region" {} +variable "template_id" {} +variable "name" {} +variable "description" {} +variable "rrule" {} +variable "input" {} + +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 = 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 new file mode 100644 index 000000000..2f46d382f --- /dev/null +++ b/stackit/internal/services/automation/testdata/resource-min.tf @@ -0,0 +1,17 @@ +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 = var.input + triggers = { + schedule = { + rrule = var.rrule + } + } +} 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 new file mode 100644 index 000000000..4df27ff92 --- /dev/null +++ b/stackit/internal/services/automation/volume/datasource.go @@ -0,0 +1,183 @@ +package volume + +import ( + "context" + "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-log/tflog" + + 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" + "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.StringAttribute{ + CustomType: jsontypes.NormalizedType{}, + Description: descriptions["input"], + 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..ddce150a6 --- /dev/null +++ b/stackit/internal/services/automation/volume/resource.go @@ -0,0 +1,566 @@ +package volume + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "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/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/v1api" + + "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{} +) + +// 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 jsontypes.Normalized `tfsdk:"input"` + Triggers *triggersModel `tfsdk:"triggers"` +} + +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. 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.", + "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.StringAttribute{ + CustomType: jsontypes.NormalizedType{}, + Description: descriptions["input"], + Optional: true, + }, + "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(), + }, + }, + }, + }, + }, + }, + }, + } +} + +// 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(&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 { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && 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(&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. + _, 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 { + 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 { + 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)) + 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(_ 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) + + model.TemplateId = types.StringPointerValue(apiResp.TemplateId) + model.Name = types.StringPointerValue(apiResp.Name) + model.Description = types.StringPointerValue(apiResp.Description) + + var inputString *string + if apiResp.Input.Get() != nil { + inputJson, err := apiResp.Input.MarshalJSON() + if err != nil { + return fmt.Errorf("error marshaling input field: %w", err) + } + inputString = new(string(inputJson)) + } + model.Input = jsontypes.NewNormalizedPointerValue(inputString) + + model.Triggers = mapTriggers(apiResp.Triggers) + + return 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.Get().Schedule.Get().Rrule), + }, + } +} + +func toCreatePayload(model *Model) (*automation.CreateVolumeAutomationPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + input, err := toInputPayload(model.Input) + if err != nil { + return nil, err + } + + return &automation.CreateVolumeAutomationPayload{ + TemplateId: model.TemplateId.ValueString(), + Name: *automation.NewNullableString(conversion.StringValueToPointer(model.Name)), + Description: *automation.NewNullableString(conversion.StringValueToPointer(model.Description)), + Input: *input, + Triggers: *toTriggersPayload(model), + }, nil +} + +func toUpdatePayload(plan *Model) (*automation.PartialUpdateVolumeAutomationPayload, error) { + if plan == nil { + return nil, fmt.Errorf("nil plan model") + } + + input, err := toInputPayload(plan.Input) + if err != nil { + return nil, err + } + + return &automation.PartialUpdateVolumeAutomationPayload{ + Name: *automation.NewNullableString(conversion.StringValueToPointer(plan.Name)), + Description: *automation.NewNullableString(conversion.StringValueToPointer(plan.Description)), + Input: *input, + Triggers: *toTriggersPayload(plan), + }, nil +} + +func toInputPayload(modelInput jsontypes.Normalized) (*automation.NullableVolumeAutomationInput, error) { + if utils.IsUndefined(modelInput) { + return automation.NewNullableVolumeAutomationInput(nil), nil + } + + inputJson := modelInput.ValueString() + inputPayload := &automation.NullableVolumeAutomationInput{} + + err := json.Unmarshal([]byte(inputJson), inputPayload) + if err != nil { + return nil, fmt.Errorf("unmarshaling input payload: %w", err) + } + return inputPayload, nil +} + +func toTriggersPayload(model *Model) *automation.NullableAutomationTriggers { + if model == nil || model.Triggers == nil { + return automation.NewNullableAutomationTriggers(nil) + } + if model.Triggers.Schedule == nil || utils.IsUndefined(model.Triggers.Schedule.Rrule) { + return automation.NewNullableAutomationTriggers(&automation.AutomationTriggers{ + Schedule: *automation.NewNullableAutomationScheduleTrigger(nil), + }) + } + + 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 new file mode 100644 index 000000000..c36a47a10 --- /dev/null +++ b/stackit/internal/services/automation/volume/resource_test.go @@ -0,0 +1,336 @@ +package volume + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/types" + + automation "github.com/stackitcloud/stackit-sdk-go/services/automation/v1api" +) + +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, + }, + { + "empty_strings_for_name_and_description", + &automation.VolumeAutomation{ + Id: "automation_uid", + Name: new(""), + Description: new(""), + Input: *automation.NewNullableVolumeAutomationInput(&automation.VolumeAutomationInput{ + Kind: "VolumeRecoveryPointManagementInput", + AdditionalProperties: map[string]interface{}{ + "volumeLabelSelector": "", + "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.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.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) { + 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", + 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, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toCreatePayload(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 { + cmpOpts := cmp.Options{ + cmp.AllowUnexported( + automation.NullableVolumeAutomationInput{}, + automation.NullableAutomationTriggers{}, + automation.NullableAutomationScheduleTrigger{}, + automation.NullableString{}, + ), + } + diff := cmp.Diff(tt.expected, output, cmpOpts) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + 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: 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{ + Name: *automation.NewNullableString(nil), + Description: *automation.NewNullableString(nil), + Input: *automation.NewNullableVolumeAutomationInput(nil), + Triggers: *automation.NewNullableAutomationTriggers(nil), + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdatePayload(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 { + cmpOpts := cmp.Options{ + cmp.AllowUnexported( + automation.NullableVolumeAutomationInput{}, + automation.NullableAutomationTriggers{}, + automation.NullableAutomationScheduleTrigger{}, + automation.NullableString{}, + ), + } + diff := cmp.Diff(output, tt.expected, cmpOpts) + 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,