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,