Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/data-sources/postgresflex_instance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions docs/resources/postgresflex_instance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```

<!-- schema generated by tfplugindocs -->
Expand All @@ -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`
Expand Down
18 changes: 18 additions & 0 deletions examples/resources/stackit_postgresflex_instance/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions stackit/internal/services/postgresflex/instance/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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(),
},
},
},
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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)
}
}
})
}
}
Loading