feat: plan modifiers handling

This commit is contained in:
Marcel S. Henselin 2026-01-23 17:07:02 +01:00
parent c9c6dd1852
commit 7c4ca25791
13 changed files with 428 additions and 555 deletions

View file

@ -0,0 +1,11 @@
fields:
- name: 'backup_schedule'
modifiers:
- 'UseStateForUnknown'
- 'RequiresReplace'
- name: 'encryption.kek_key_id'
modifiers:
- 'RequiresReplace'
- name: 'network.acl'
modifiers:
- 'RequiresReplace'

View file

@ -2,6 +2,7 @@ package postgresflexalpha
import (
"context"
_ "embed"
"fmt"
"math"
"net/http"
@ -9,6 +10,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/identityschema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
postgresflex "github.com/mhenselin/terraform-provider-stackitprivatepreview/pkg/postgresflexalpha"
@ -28,7 +30,7 @@ var (
_ resource.ResourceWithImportState = &instanceResource{}
_ resource.ResourceWithModifyPlan = &instanceResource{}
_ resource.ResourceWithValidateConfig = &instanceResource{}
// _ resource.ResourceWithIdentity = &instanceResource{}
_ resource.ResourceWithIdentity = &instanceResource{}
)
// NewInstanceResource is a helper function to simplify the provider implementation.
@ -42,6 +44,12 @@ type instanceResource struct {
providerData core.ProviderData
}
type InstanceResourceIdentityModel struct {
ProjectID types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
InstanceID types.String `tfsdk:"instance_id"`
}
func (r *instanceResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data postgresflexalpha.InstanceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
@ -115,9 +123,44 @@ func (r *instanceResource) Configure(
tflog.Info(ctx, "Postgres Flex instance client configured")
}
/*
until tfplugingen framework can handle plan modifiers, we use a function to add them
*/
//go:embed planModifiers.yaml
var modifiersFileByte []byte
// Schema defines the schema for the resource.
func (r *instanceResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = postgresflexalpha.InstanceResourceSchema(ctx)
schema := postgresflexalpha.InstanceResourceSchema(ctx)
fields, err := postgresflexUtils.ReadModifiersConfig(modifiersFileByte)
if err != nil {
resp.Diagnostics.AddError("error during read modifiers config file", err.Error())
return
}
err = postgresflexUtils.AddPlanModifiersToResourceSchema(fields, &schema)
if err != nil {
resp.Diagnostics.AddError("error adding plan modifiers", err.Error())
return
}
resp.Schema = schema
}
func (r *instanceResource) IdentitySchema(_ context.Context, _ resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) {
resp.IdentitySchema = identityschema.Schema{
Attributes: map[string]identityschema.Attribute{
"project_id": identityschema.StringAttribute{
RequiredForImport: true, // must be set during import by the practitioner
},
"region": identityschema.StringAttribute{
RequiredForImport: true, // can be defaulted by the provider configuration
},
"instance_id": identityschema.StringAttribute{
RequiredForImport: true, // can be defaulted by the provider configuration
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
@ -158,7 +201,7 @@ func (r *instanceResource) Create(
// Create new instance
createResp, err := r.client.CreateInstanceRequest(ctx, projectId, region).CreateInstanceRequestPayload(payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Calling API: %v", err))
core.LogAndAddError(ctx, &resp.Diagnostics, "error creating instance", fmt.Sprintf("Calling API: %v", err))
return
}
@ -172,6 +215,17 @@ func (r *instanceResource) Create(
return
}
// Set data returned by API in identity
identity := InstanceResourceIdentityModel{
ProjectID: types.StringValue(projectId),
Region: types.StringValue(region),
InstanceID: types.StringValue(instanceId),
}
resp.Diagnostics.Append(resp.Identity.Set(ctx, identity)...)
if resp.Diagnostics.HasError() {
return
}
waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client, projectId, region, instanceId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Wait handler error: %v", err))
@ -225,6 +279,7 @@ func modelToCreateInstancePayload(netAcl []string, model postgresflexalpha.Insta
// Read refreshes the Terraform state with the latest data.
func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform
functionError := "read instance failed"
var model postgresflexalpha.InstanceModel
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
@ -232,6 +287,13 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r
return
}
// Read identity data
var identityData InstanceResourceIdentityModel
resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
@ -248,15 +310,32 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", err.Error())
core.LogAndAddError(ctx, &resp.Diagnostics, functionError, err.Error())
return
}
ctx = core.LogResponse(ctx)
respInstanceID, ok := instanceResp.GetIdOk()
if !ok {
core.LogAndAddError(ctx, &resp.Diagnostics, functionError, "response provided no ID")
return
}
if !model.InstanceId.IsUnknown() && !model.InstanceId.IsNull() {
if respInstanceID != instanceId {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
functionError,
"ID in response did not match ID in state",
)
return
}
}
err = mapGetInstanceResponseToModel(ctx, &model, instanceResp)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API payload: %v", err))
core.LogAndAddError(ctx, &resp.Diagnostics, functionError, fmt.Sprintf("Processing API payload: %v", err))
return
}
@ -266,6 +345,17 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r
return
}
// Set data returned by API in identity
identity := InstanceResourceIdentityModel{
ProjectID: types.StringValue(projectId),
Region: types.StringValue(region),
InstanceID: types.StringValue(instanceId),
}
resp.Diagnostics.Append(resp.Identity.Set(ctx, identity)...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex instance read")
}

View file

@ -0,0 +1,229 @@
package utils
import (
"fmt"
"log/slog"
"reflect"
"slices"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/iancoleman/strcase"
"gopkg.in/yaml.v3"
)
type Field struct {
Name string `yaml:"name"`
Modifiers []*string `yaml:"modifiers"`
}
type Fields struct {
Fields []*Field `yaml:"fields"`
}
var validModifiers = []string{
"UseStateForUnknown",
"RequiresReplace",
}
func ReadModifiersConfig(content []byte) (*Fields, error) {
var fields Fields
err := yaml.Unmarshal(content, &fields)
if err != nil {
return nil, err
}
return &fields, nil
}
func AddPlanModifiersToResourceSchema(fields *Fields, s *schema.Schema) error {
err := validateFields(fields)
if err != nil {
return err
}
resAttr, err := handleAttributes("", s.Attributes, fields)
if err != nil {
return err
}
s.Attributes = resAttr
return nil
}
func handleAttributes(prefix string, attributes map[string]schema.Attribute, fields *Fields) (map[string]schema.Attribute, error) {
fieldMap := fieldListToMap(fields)
for attrName, attrValue := range attributes {
attrNameSnake := strcase.ToSnake(attrName)
if prefix != "" {
attrNameSnake = prefix + "." + attrNameSnake
}
switch reflect.TypeOf(attrValue).String() {
case "schema.BoolAttribute":
if field, ok := fieldMap[attrNameSnake]; ok {
res, err := handleBoolPlanModifiers(attrValue, field)
if err != nil {
return nil, err
}
attributes[attrName] = res
}
case "schema.Int64Attribute":
if field, ok := fieldMap[attrNameSnake]; ok {
res, err := handleInt64PlanModifiers(attrValue, field)
if err != nil {
return nil, err
}
attributes[attrName] = res
}
case "schema.StringAttribute":
if field, ok := fieldMap[attrNameSnake]; ok {
res, err := handleStringPlanModifiers(attrValue, field)
if err != nil {
return nil, err
}
attributes[attrName] = res
}
case "schema.ListAttribute":
if field, ok := fieldMap[attrNameSnake]; ok {
res, err := handleListPlanModifiers(attrValue, field)
if err != nil {
return nil, err
}
attributes[attrName] = res
}
case "schema.SingleNestedAttribute":
nested, ok := attrValue.(schema.SingleNestedAttribute)
if !ok {
if _, ok := attrValue.(interface {
GetAttributes() map[string]schema.Attribute
}); ok {
return nil, fmt.Errorf("unsupported type for single nested attribute")
}
}
res, err := handleAttributes(attrName, nested.Attributes, fields)
if err != nil {
return nil, err
}
nested.Attributes = res
attributes[attrName] = nested
default:
slog.Warn("type currently not supported", "type", reflect.TypeOf(attrValue).String())
}
}
return attributes, nil
}
func handleBoolPlanModifiers(
attr schema.Attribute,
fields []*string,
) (schema.Attribute, error) {
a, ok := attr.(schema.BoolAttribute)
if !ok {
return nil, fmt.Errorf("field is not a string attribute")
}
for _, v := range fields {
switch *v {
case "RequiresReplace":
a.PlanModifiers = append(a.PlanModifiers, boolplanmodifier.RequiresReplace())
case "UseStateForUnknown":
a.PlanModifiers = append(a.PlanModifiers, boolplanmodifier.UseStateForUnknown())
}
}
return a, nil
}
func handleStringPlanModifiers(
attr schema.Attribute,
fields []*string,
) (schema.Attribute, error) {
a, ok := attr.(schema.StringAttribute)
if !ok {
return nil, fmt.Errorf("field is not a string attribute")
}
for _, v := range fields {
switch *v {
case "RequiresReplace":
a.PlanModifiers = append(a.PlanModifiers, stringplanmodifier.RequiresReplace())
case "UseStateForUnknown":
a.PlanModifiers = append(a.PlanModifiers, stringplanmodifier.UseStateForUnknown())
}
}
return a, nil
}
func handleInt64PlanModifiers(
attr schema.Attribute,
fields []*string,
) (schema.Attribute, error) {
a, ok := attr.(schema.Int64Attribute)
if !ok {
return nil, fmt.Errorf("field is not a string attribute")
}
for _, v := range fields {
switch *v {
case "RequiresReplace":
a.PlanModifiers = append(a.PlanModifiers, int64planmodifier.RequiresReplace())
case "UseStateForUnknown":
a.PlanModifiers = append(a.PlanModifiers, int64planmodifier.UseStateForUnknown())
}
}
return a, nil
}
func handleListPlanModifiers(
attr schema.Attribute,
fields []*string,
) (schema.Attribute, error) {
a, ok := attr.(schema.ListAttribute)
if !ok {
return nil, fmt.Errorf("field is not a string attribute")
}
for _, v := range fields {
switch *v {
case "RequiresReplace":
a.PlanModifiers = append(a.PlanModifiers, listplanmodifier.RequiresReplace())
case "UseStateForUnknown":
a.PlanModifiers = append(a.PlanModifiers, listplanmodifier.UseStateForUnknown())
}
}
return a, nil
}
func validateFields(fields *Fields) error {
if fields == nil {
return nil
}
for _, field := range fields.Fields {
for _, modifier := range field.Modifiers {
if *modifier == "" {
return fmt.Errorf("modifier %s is required", *modifier)
}
if !slices.Contains(validModifiers, *modifier) {
return fmt.Errorf("modifier %s is invalid", *modifier)
}
}
}
return nil
}
func fieldListToMap(fields *Fields) map[string][]*string {
res := make(map[string][]*string)
if fields != nil {
for _, field := range fields.Fields {
res[field.Name] = field.Modifiers
}
} else {
slog.Warn("no fields available")
}
return res
}

View file

@ -53,8 +53,9 @@ func CreateInstanceWaitHandler(
) *wait.AsyncActionHandler[postgresflex.GetInstanceResponse] {
instanceCreated := false
var instanceGetResponse *postgresflex.GetInstanceResponse
maxWait := time.Minute * 30
maxWait := time.Minute * 45
startTime := time.Now()
extendedTimeout := 0
handler := wait.New(
func() (waitFinished bool, response *postgresflex.GetInstanceResponse, err error) {
@ -88,11 +89,32 @@ func CreateInstanceWaitHandler(
instanceId,
),
)
if extendedTimeout < 3 {
maxWait = maxWait + time.Minute*5
extendedTimeout = extendedTimeout + 1
if s.Network == nil || s.Network.InstanceAddress == nil {
tflog.Warn(ctx, "Waiting for instance_address")
return false, nil, nil
}
if s.Network.RouterAddress == nil {
tflog.Warn(ctx, "Waiting for router_address")
return false, nil, nil
}
if s.Status == nil {
tflog.Warn(ctx, "Waiting for status")
return false, nil, nil
}
if s.IsDeletable == nil {
tflog.Warn(ctx, "Waiting for is_deletable")
return false, nil, nil
}
}
instanceCreated = true
instanceGetResponse = s
case InstanceStateSuccess:
if s.Network == nil || s.Network.InstanceAddress == nil {
tflog.Info(ctx, "Waiting for instance_address")
tflog.Warn(ctx, "Waiting for instance_address")
return false, nil, nil
}
if s.Network.RouterAddress == nil {