From 06412d50c32022f8bd455c4b6ec9129569013dbb Mon Sep 17 00:00:00 2001 From: Marc Adrian Date: Fri, 9 Oct 2026 13:37:32 +0200 Subject: [PATCH] feat(postgresflex): add instance deletion protection --- docs/data-sources/postgresflex_instance.md | 1 + docs/resources/postgresflex_instance.md | 19 ++++ .../stackit_postgresflex_instance/resource.tf | 18 ++++ .../postgresflex/instance/datasource.go | 4 + .../postgresflex/instance/resource.go | 70 +++++++++++++++ .../postgresflex/instance/resource_test.go | 90 +++++++++++++++++-- .../postgresflex/postgresflex_acc_test.go | 34 ++++--- .../testdata/resource-instance-max.tf | 6 +- 8 files changed, 218 insertions(+), 24 deletions(-) diff --git a/docs/data-sources/postgresflex_instance.md b/docs/data-sources/postgresflex_instance.md index 8a8abb928..072679dc0 100644 --- a/docs/data-sources/postgresflex_instance.md +++ b/docs/data-sources/postgresflex_instance.md @@ -36,6 +36,7 @@ data "stackit_postgresflex_instance" "example" { - `acl` (List of String, Deprecated) The Access Control List (ACL) for the PostgresFlex instance. - `backup_schedule` (String) The schedule for on what time and how often the database backup will be created. Must be a valid cron expression using numeric minute and hour values, e.g: '0 2 * * *'. - `connection_info` (Attributes) The connection info for the PostgresFlex instance. (see [below for nested schema](#nestedatt--connection_info)) +- `deletion_protection` (Boolean) Whether the instance is protected from deletion. - `encryption` (Attributes) (see [below for nested schema](#nestedatt--encryption)) - `flavor` (Attributes, Deprecated) (see [below for nested schema](#nestedatt--flavor)) - `flavor_id` (String) diff --git a/docs/resources/postgresflex_instance.md b/docs/resources/postgresflex_instance.md index 768c4b7fe..29e0b47bf 100644 --- a/docs/resources/postgresflex_instance.md +++ b/docs/resources/postgresflex_instance.md @@ -28,6 +28,24 @@ resource "stackit_postgresflex_instance" "example" { version = "17" retention_days = 32 } + +# Instance with deletion protection +resource "stackit_postgresflex_instance" "protected" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-protected-instance" + network = { + acl = ["XXX.XXX.XXX.X/XX", "XX.XXX.XX.X/XX"] + } + backup_schedule = "0 0 * * *" + flavor_id = "4.8-replica" + storage = { + class = "premium-perf2-stackit" + size = 5 + } + version = "17" + retention_days = 32 + deletion_protection = true +} ``` @@ -44,6 +62,7 @@ resource "stackit_postgresflex_instance" "example" { ### Optional - `acl` (List of String, Deprecated) The Access Control List (ACL) for the PostgresFlex instance. +- `deletion_protection` (Boolean) If set to `true`, the instance is protected from deletion. The protection must be disabled (set to `false`) before the instance can be destroyed. If not set, the current value of the instance is kept. - `encryption` (Attributes) (see [below for nested schema](#nestedatt--encryption)) - `flavor` (Attributes, Deprecated) (see [below for nested schema](#nestedatt--flavor)) - `flavor_id` (String) The flavor ID of the PostgreSQL Flex instance. Can only be set when `flavor` and `replicas` are not set. You can list available flavors using the datasource `stackit_postgresflex_flavors` diff --git a/examples/resources/stackit_postgresflex_instance/resource.tf b/examples/resources/stackit_postgresflex_instance/resource.tf index 6ec923662..1a6c26d0b 100644 --- a/examples/resources/stackit_postgresflex_instance/resource.tf +++ b/examples/resources/stackit_postgresflex_instance/resource.tf @@ -13,3 +13,21 @@ resource "stackit_postgresflex_instance" "example" { version = "17" retention_days = 32 } + +# Instance with deletion protection +resource "stackit_postgresflex_instance" "protected" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-protected-instance" + network = { + acl = ["XXX.XXX.XXX.X/XX", "XX.XXX.XX.X/XX"] + } + backup_schedule = "0 0 * * *" + flavor_id = "4.8-replica" + storage = { + class = "premium-perf2-stackit" + size = 5 + } + version = "17" + retention_days = 32 + deletion_protection = true +} diff --git a/stackit/internal/services/postgresflex/instance/datasource.go b/stackit/internal/services/postgresflex/instance/datasource.go index a664cf4f6..a3d90523c 100644 --- a/stackit/internal/services/postgresflex/instance/datasource.go +++ b/stackit/internal/services/postgresflex/instance/datasource.go @@ -183,6 +183,10 @@ func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques Description: descriptions["retention_days"], Computed: true, }, + "deletion_protection": schema.BoolAttribute{ + Description: "Whether the instance is protected from deletion.", + Computed: true, + }, "network": schema.SingleNestedAttribute{ Description: descriptions["network"], Computed: true, diff --git a/stackit/internal/services/postgresflex/instance/resource.go b/stackit/internal/services/postgresflex/instance/resource.go index 80c166c3f..ea086e7b3 100644 --- a/stackit/internal/services/postgresflex/instance/resource.go +++ b/stackit/internal/services/postgresflex/instance/resource.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -76,6 +77,8 @@ type Model struct { RetentionDays types.Int32 `tfsdk:"retention_days"` Version types.String `tfsdk:"version"` Region types.String `tfsdk:"region"` + // DeletionProtection is the inverse of the API field `isDeletable` + DeletionProtection types.Bool `tfsdk:"deletion_protection"` } // Deprecated: Will be removed after February 2027. Struct corresponding to Model.Flavor @@ -270,6 +273,7 @@ func (r *instanceResource) Schema(_ context.Context, req resource.SchemaRequest, "network.access_scope": "The network access scope of the instance. This feature is in private preview. Supplying this object is only permitted for enabled accounts. If your account does not have access, the request will be rejected. " + utils.FormatPossibleValues(sdkUtils.EnumSliceToStringSlice(postgresflex.AllowedInstanceNetworkAccessScopeEnumValues)...), "network.acl": "List of IPV4 cidr." + willBeRequired, "retention_days": "How long backups are retained. The value can only be between 32 and 90 days." + willBeRequired, + "deletion_protection": "If set to `true`, the instance is protected from deletion. The protection must be disabled (set to `false`) before the instance can be destroyed. If not set, the current value of the instance is kept.", } resp.Schema = schema.Schema{ @@ -542,6 +546,14 @@ func (r *instanceResource) Schema(_ context.Context, req resource.SchemaRequest, stringplanmodifier.RequiresReplace(), }, }, + "deletion_protection": schema.BoolAttribute{ + Description: descriptions["deletion_protection"], + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, }, } } @@ -645,6 +657,15 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques return } + // The deletion protection can't be set in the create request, it has to be enabled afterwards + if model.DeletionProtection.ValueBool() { + err = r.updateDeletionProtection(ctx, projectId, region, createResp.Id, &model, waitResp) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Enabling deletion protection: %v", err)) + return + } + } + // Map response body to schema err = mapFields(ctx, waitResp, &model, flavor, region) if err != nil { @@ -810,6 +831,15 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques return } + // The deletion protection is managed by a separate endpoint + if !model.DeletionProtection.IsNull() && !model.DeletionProtection.IsUnknown() && model.DeletionProtection.ValueBool() == waitResp.IsDeletable { + err = r.updateDeletionProtection(ctx, projectId, region, instanceId, &model, waitResp) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Updating deletion protection: %v", err)) + return + } + } + // Map response body to schema err = mapFields(ctx, waitResp, &model, flavor, region) if err != nil { @@ -843,6 +873,11 @@ func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteReques ctx = tflog.SetField(ctx, "instance_id", instanceId) ctx = tflog.SetField(ctx, "region", region) + if model.DeletionProtection.ValueBool() { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting instance", "The instance is protected from deletion. Set `deletion_protection` to `false` and apply the change before destroying the instance.") + return + } + // Delete existing instance err := r.client.DeleteInstance(ctx, projectId, region, instanceId).Execute() if err != nil { @@ -885,6 +920,27 @@ func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportS tflog.Info(ctx, "Postgres Flex instance state imported") } +// updateDeletionProtection sets the deletion protection of the instance to the value of the model +// and updates the given instance response with the resulting value. +func (r *instanceResource) updateDeletionProtection(ctx context.Context, projectId, region, instanceId string, model *Model, instanceResp *postgresflex.GetInstanceResponse) error { + if instanceResp == nil { + return fmt.Errorf("instance response is nil") + } + payload, err := toUpdateProtectionPayload(model) + if err != nil { + return fmt.Errorf("creating API payload: %w", err) + } + protectionResp, err := r.client.UpdateInstanceProtection(ctx, projectId, region, instanceId).UpdateInstanceProtectionPayload(*payload).Execute() + if err != nil { + return fmt.Errorf("calling API: %w", err) + } + if protectionResp == nil { + return fmt.Errorf("got empty response") + } + instanceResp.IsDeletable = protectionResp.IsDeletable + return nil +} + func mapFields(ctx context.Context, resp *postgresflex.GetInstanceResponse, model *Model, flavor *flavorModel, region string) error { if resp == nil { return fmt.Errorf("response input is nil") @@ -1012,6 +1068,7 @@ func mapFields(ctx context.Context, resp *postgresflex.GetInstanceResponse, mode model.Region = types.StringValue(region) model.Network = networkObject model.ConnectionInfo = connectionObject + model.DeletionProtection = types.BoolValue(!resp.IsDeletable) return nil } @@ -1132,6 +1189,19 @@ func toUpdatePayload(model *Model, acl []string, flavor *flavorModel, storage *s }, nil } +func toUpdateProtectionPayload(model *Model) (*postgresflex.UpdateInstanceProtectionPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + if model.DeletionProtection.IsNull() || model.DeletionProtection.IsUnknown() { + return nil, fmt.Errorf("deletion protection is not set") + } + + return &postgresflex.UpdateInstanceProtectionPayload{ + IsDeletable: !model.DeletionProtection.ValueBool(), + }, nil +} + type postgresFlexClient interface { ListFlavors(ctx context.Context, projectId, region string) postgresflex.ApiListFlavorsRequest ListFlavorsExecute(r postgresflex.ApiListFlavorsRequest) (*postgresflex.ListFlavorsResponse, error) diff --git a/stackit/internal/services/postgresflex/instance/resource_test.go b/stackit/internal/services/postgresflex/instance/resource_test.go index 89974d2d9..32990c1a1 100644 --- a/stackit/internal/services/postgresflex/instance/resource_test.go +++ b/stackit/internal/services/postgresflex/instance/resource_test.go @@ -78,8 +78,9 @@ func TestMapFields(t *testing.T) { "instance_address": types.StringNull(), "router_address": types.StringNull(), }), - Version: types.StringValue(""), - Region: types.StringValue(testRegion), + Version: types.StringValue(""), + Region: types.StringValue(testRegion), + DeletionProtection: types.BoolValue(true), } for _, mod := range mods { @@ -129,6 +130,7 @@ func TestMapFields(t *testing.T) { Id: "iid", Name: "name", State: postgresflex.STATE_READY, + IsDeletable: true, Storage: postgresflex.Storage{ Class: new("class"), Size: new(int64(78)), @@ -177,8 +179,9 @@ func TestMapFields(t *testing.T) { "class": types.StringValue("class"), "size": types.Int64Value(78), }), - Version: types.StringValue("version"), - Region: types.StringValue(testRegion), + Version: types.StringValue("version"), + Region: types.StringValue(testRegion), + DeletionProtection: types.BoolValue(false), }, isValid: true, }, @@ -254,8 +257,9 @@ func TestMapFields(t *testing.T) { "class": types.StringValue("class"), "size": types.Int64Value(78), }), - Version: types.StringValue("version"), - Region: types.StringValue(testRegion), + Version: types.StringValue("version"), + Region: types.StringValue(testRegion), + DeletionProtection: types.BoolValue(true), }, isValid: true, }, @@ -333,8 +337,9 @@ func TestMapFields(t *testing.T) { "instance_address": types.StringNull(), "router_address": types.StringNull(), }), - Version: types.StringValue("version"), - Region: types.StringValue(testRegion), + Version: types.StringValue("version"), + Region: types.StringValue(testRegion), + DeletionProtection: types.BoolValue(true), }, isValid: true, }, @@ -1297,3 +1302,72 @@ func TestGetAllFlavors(t *testing.T) { }) } } + +func TestToUpdateProtectionPayload(t *testing.T) { + tests := []struct { + description string + input *Model + expected *postgresflex.UpdateInstanceProtectionPayload + isValid bool + }{ + { + description: "protection_enabled", + input: &Model{ + DeletionProtection: types.BoolValue(true), + }, + expected: &postgresflex.UpdateInstanceProtectionPayload{ + IsDeletable: false, + }, + isValid: true, + }, + { + description: "protection_disabled", + input: &Model{ + DeletionProtection: types.BoolValue(false), + }, + expected: &postgresflex.UpdateInstanceProtectionPayload{ + IsDeletable: true, + }, + isValid: true, + }, + { + description: "protection_null", + input: &Model{ + DeletionProtection: types.BoolNull(), + }, + expected: nil, + isValid: false, + }, + { + description: "protection_unknown", + input: &Model{ + DeletionProtection: types.BoolUnknown(), + }, + expected: nil, + isValid: false, + }, + { + description: "nil_model", + input: nil, + expected: nil, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + output, err := toUpdateProtectionPayload(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) + } + } + }) + } +} diff --git a/stackit/internal/services/postgresflex/postgresflex_acc_test.go b/stackit/internal/services/postgresflex/postgresflex_acc_test.go index a5922ec4b..a0ef6939b 100644 --- a/stackit/internal/services/postgresflex/postgresflex_acc_test.go +++ b/stackit/internal/services/postgresflex/postgresflex_acc_test.go @@ -77,20 +77,21 @@ var testConfigInstanceVarsMinUpdated = func() config.Variables { // Instance - MAX var testConfigInstanceVarsMax = config.Variables{ - "project_id": config.StringVariable(testutil.ProjectId), - "name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), - "acl": config.StringVariable("192.168.0.0/24"), - "access_scope": config.StringVariable(string(postgresflex.INSTANCENETWORKACCESSSCOPE_PUBLIC)), - "backup_schedule": config.StringVariable("0 16 * * *"), - "flavor_id": config.StringVariable("4.8-replica"), - "flavor_cpu": config.IntegerVariable(4), - "flavor_ram": config.IntegerVariable(8), - "replicas": config.IntegerVariable(3), - "storage_class": config.StringVariable("premium-perf2-stackit"), - "storage_size": config.IntegerVariable(5), - "instance_version": config.StringVariable("16"), - "retention_days": config.IntegerVariable(40), - "region": config.StringVariable(testutil.Region), + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "acl": config.StringVariable("192.168.0.0/24"), + "access_scope": config.StringVariable(string(postgresflex.INSTANCENETWORKACCESSSCOPE_PUBLIC)), + "backup_schedule": config.StringVariable("0 16 * * *"), + "flavor_id": config.StringVariable("4.8-replica"), + "flavor_cpu": config.IntegerVariable(4), + "flavor_ram": config.IntegerVariable(8), + "replicas": config.IntegerVariable(3), + "storage_class": config.StringVariable("premium-perf2-stackit"), + "storage_size": config.IntegerVariable(5), + "instance_version": config.StringVariable("16"), + "retention_days": config.IntegerVariable(40), + "region": config.StringVariable(testutil.Region), + "deletion_protection": config.BoolVariable(true), } var testConfigInstanceVarsMaxUpdated = func() config.Variables { @@ -108,6 +109,8 @@ var testConfigInstanceVarsMaxUpdated = func() config.Variables { updatedConfig["storage_size"] = config.IntegerVariable(11) updatedConfig["instance_version"] = config.StringVariable("17") updatedConfig["retention_days"] = config.IntegerVariable(32) + // protection must be disabled again, otherwise the instance can't be destroyed at the end of the test + updatedConfig["deletion_protection"] = config.BoolVariable(false) return updatedConfig }() @@ -349,6 +352,7 @@ func TestAccPostgresFlexInstanceMax(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "connection_info.write.port"), resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "replicas"), resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "retention_days", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["retention_days"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "deletion_protection", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["deletion_protection"])), resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_class"])), resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_size"])), resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["instance_version"])), @@ -424,6 +428,7 @@ func TestAccPostgresFlexInstanceMax(t *testing.T) { resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "connection_info.write.port"), resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "replicas"), resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "retention_days", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["retention_days"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "deletion_protection", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["deletion_protection"])), resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_class"])), resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_size"])), resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["instance_version"])), @@ -539,6 +544,7 @@ func TestAccPostgresFlexInstanceMax(t *testing.T) { resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "connection_info.write.port"), resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "replicas"), resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "retention_days", retentionDaysDefault), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "deletion_protection", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["deletion_protection"])), resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["storage_class"])), resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["storage_size"])), resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["instance_version"])), diff --git a/stackit/internal/services/postgresflex/testdata/resource-instance-max.tf b/stackit/internal/services/postgresflex/testdata/resource-instance-max.tf index 1b3a08497..4817b61b7 100644 --- a/stackit/internal/services/postgresflex/testdata/resource-instance-max.tf +++ b/stackit/internal/services/postgresflex/testdata/resource-instance-max.tf @@ -12,6 +12,7 @@ variable "flavor_cpu" {} variable "flavor_ram" {} variable "replicas" {} variable "region" {} +variable "deletion_protection" {} resource "stackit_postgresflex_instance" "with_flavor_id" { project_id = var.project_id @@ -26,8 +27,9 @@ resource "stackit_postgresflex_instance" "with_flavor_id" { class = var.storage_class size = var.storage_size } - version = var.instance_version - retention_days = var.retention_days + version = var.instance_version + retention_days = var.retention_days + deletion_protection = var.deletion_protection } resource "stackit_postgresflex_instance" "with_flavor" {