feat: refactor database resource model and enhance identity schema handling

This commit is contained in:
Andre_Harms 2026-02-04 20:48:53 +01:00
parent c96328060d
commit 502b2f5e0a
3 changed files with 699 additions and 408 deletions

View file

@ -2,31 +2,26 @@ package postgresflexalpha
import (
"context"
_ "embed"
"errors"
"fmt"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"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/identityschema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"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"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/pkg_gen/postgresflexalpha"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
postgresflexalpha2 "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/database/resources_gen"
postgresflexUtils "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/utils"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
@ -35,16 +30,21 @@ var (
_ resource.ResourceWithConfigure = &databaseResource{}
_ resource.ResourceWithImportState = &databaseResource{}
_ resource.ResourceWithModifyPlan = &databaseResource{}
_ resource.ResourceWithIdentity = &databaseResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
DatabaseId types.Int64 `tfsdk:"database_id"`
InstanceId types.String `tfsdk:"instance_id"`
ProjectId types.String `tfsdk:"project_id"`
Name types.String `tfsdk:"name"`
Owner types.String `tfsdk:"owner"`
// ResourceModel describes the resource data model.
type ResourceModel struct {
postgresflexalpha2.DatabaseModel
TerraformID types.String `tfsdk:"tf_id"`
}
// DatabaseResourceIdentityModel describes the resource's identity attributes.
type DatabaseResourceIdentityModel struct {
ProjectID types.String `tfsdk:"project_id"`
Region types.String `tfsdk:"region"`
InstanceID types.String `tfsdk:"instance_id"`
DatabaseID types.Int64 `tfsdk:"id"`
}
// NewDatabaseResource is a helper function to simplify the provider implementation.
@ -58,14 +58,13 @@ type databaseResource struct {
providerData core.ProviderData
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
// ModifyPlan adjusts the plan to set the correct region.
func (r *databaseResource) ModifyPlan(
ctx context.Context,
req resource.ModifyPlanRequest,
resp *resource.ModifyPlanResponse,
) { // nolint:gocritic // function signature required by Terraform
var configModel Model
var configModel ResourceModel
// skip initial empty configuration to avoid follow-up errors
if req.Config.Raw.IsNull() {
return
@ -75,13 +74,13 @@ func (r *databaseResource) ModifyPlan(
return
}
var planModel Model
var planModel ResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
if resp.Diagnostics.HasError() {
return
}
utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
//TODO utils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
if resp.Diagnostics.HasError() {
return
}
@ -117,85 +116,52 @@ func (r *databaseResource) Configure(
tflog.Info(ctx, "Postgres Flex database client configured")
}
//go:embed planModifiers.yaml
var modifiersFileByte []byte
// Schema defines the schema for the resource.
func (r *databaseResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
descriptions := map[string]string{
"main": "Postgres Flex database resource schema. Must have a `region` specified in the provider configuration.",
"id": "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_id`,`database_id`\".",
"database_id": "Database ID.",
"instance_id": "ID of the Postgres Flex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"name": "Database name.",
"owner": "Username of the database owner.",
"region": "The resource region. If not defined, the provider region is used.",
func (r *databaseResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
s := postgresflexalpha2.DatabaseResourceSchema(ctx)
s.Attributes["tf_id"] = schema.StringAttribute{
Description: "Terraform's internal resource ID. It is structured as \\\"`project_id`,`region`,`instance_id`,`id`\\\".\",",
Optional: true,
Computed: true,
}
resp.Schema = schema.Schema{
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
fields, err := postgresflexUtils.ReadModifiersConfig(modifiersFileByte)
if err != nil {
resp.Diagnostics.AddError("error during read modifiers config file", err.Error())
return
}
err = postgresflexUtils.AddPlanModifiersToResourceSchema(fields, &s)
if err != nil {
resp.Diagnostics.AddError("error adding plan modifiers", err.Error())
return
}
resp.Schema = s
}
// IdentitySchema defines the schema for the resource's identity attributes.
func (r *databaseResource) IdentitySchema(
_ context.Context,
_ resource.IdentitySchemaRequest,
response *resource.IdentitySchemaResponse,
) {
response.IdentitySchema = identityschema.Schema{
Attributes: map[string]identityschema.Attribute{
"project_id": identityschema.StringAttribute{
RequiredForImport: true,
},
"database_id": schema.Int64Attribute{
Description: descriptions["database_id"],
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
Validators: []validator.Int64{},
"region": identityschema.StringAttribute{
RequiredForImport: true,
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
"instance_id": identityschema.StringAttribute{
RequiredForImport: true,
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: descriptions["name"],
Required: true,
PlanModifiers: []planmodifier.String{},
Validators: []validator.String{
stringvalidator.RegexMatches(
regexp.MustCompile("^[a-z]([a-z0-9]*)?$"),
"must start with a letter, must have lower case letters or numbers",
),
},
},
"owner": schema.StringAttribute{
Description: descriptions["owner"],
Required: true,
PlanModifiers: []planmodifier.String{},
},
"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(),
},
"id": identityschema.StringAttribute{
// database id
RequiredForImport: true,
},
},
}
@ -207,18 +173,26 @@ func (r *databaseResource) Create(
req resource.CreateRequest,
resp *resource.CreateResponse,
) { // nolint:gocritic // function signature required by Terraform
var model Model
var model ResourceModel
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Read identity data
var identityData DatabaseResourceIdentityModel
resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
region := model.Region.ValueString()
instanceId := model.InstanceId.ValueString()
projectId := identityData.ProjectID.ValueString()
region := identityData.ProjectID.ValueString()
instanceId := identityData.InstanceID.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
@ -272,7 +246,7 @@ func (r *databaseResource) Create(
}
// Map response body to schema
err = mapFields(database, &model, region)
err = mapResourceFields(database, &model)
if err != nil {
core.LogAndAddError(
ctx,
@ -297,23 +271,30 @@ func (r *databaseResource) Read(
req resource.ReadRequest,
resp *resource.ReadResponse,
) { // nolint:gocritic // function signature required by Terraform
var model Model
var model ResourceModel
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Read identity data
var identityData DatabaseResourceIdentityModel
resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
databaseId := model.DatabaseId.ValueInt64()
region := r.providerData.GetRegionWithOverride(model.Region)
projectId := identityData.ProjectID.ValueString()
instanceId := identityData.InstanceID.ValueString()
databaseId := model.Id.ValueInt64()
region := r.providerData.GetRegionWithOverride(identityData.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "id", databaseId) //database id
databaseResp, err := getDatabaseById(ctx, r.client, projectId, region, instanceId, databaseId)
if err != nil {
@ -329,7 +310,7 @@ func (r *databaseResource) Read(
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(databaseResp, &model, region)
err = mapResourceFields(databaseResp, &model)
if err != nil {
core.LogAndAddError(
ctx,
@ -355,32 +336,40 @@ func (r *databaseResource) Update(
req resource.UpdateRequest,
resp *resource.UpdateResponse,
) {
var model Model
var model ResourceModel
diags := req.Plan.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Read identity data
var identityData DatabaseResourceIdentityModel
resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
databaseId64 := model.DatabaseId.ValueInt64()
projectId := identityData.ProjectID.ValueString()
instanceId := identityData.InstanceID.ValueString()
region := r.providerData.GetRegionWithOverride(identityData.Region)
databaseId64 := model.Id.ValueInt64()
if databaseId64 > math.MaxInt32 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error in type conversion", "int value too large (databaseId)")
return
}
databaseId := int32(databaseId64)
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "id", databaseId)
// Retrieve values from state
var stateModel Model
var stateModel ResourceModel
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
@ -420,7 +409,7 @@ func (r *databaseResource) Update(
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFieldsUpdatePartially(res, &model, region)
err = mapResourceFields(res.Database, &model)
if err != nil {
core.LogAndAddError(
ctx,
@ -445,29 +434,36 @@ func (r *databaseResource) Delete(
req resource.DeleteRequest,
resp *resource.DeleteResponse,
) { // nolint:gocritic // function signature required by Terraform
var model Model
var model ResourceModel
diags := req.State.Get(ctx, &model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Read identity data
var identityData DatabaseResourceIdentityModel
resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...)
if resp.Diagnostics.HasError() {
return
}
ctx = core.InitProviderContext(ctx)
projectId := model.ProjectId.ValueString()
instanceId := model.InstanceId.ValueString()
databaseId64 := model.DatabaseId.ValueInt64()
projectId := identityData.ProjectID.ValueString()
instanceId := identityData.InstanceID.ValueString()
region := r.providerData.GetRegionWithOverride(identityData.Region)
databaseId64 := model.Id.ValueInt64()
if databaseId64 > math.MaxInt32 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error in type conversion", "int value too large (databaseId)")
return
}
databaseId := int32(databaseId64)
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = tflog.SetField(ctx, "region", region)
ctx = tflog.SetField(ctx, "id", databaseId) //database id
// Delete existing record set
err := r.client.DeleteDatabaseRequestExecute(ctx, projectId, region, instanceId, databaseId)
@ -481,7 +477,7 @@ func (r *databaseResource) Delete(
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,zone_id,record_set_id
// The expected import identifier format is: [project_id],[region],[instance_id],[id]
func (r *databaseResource) ImportState(
ctx context.Context,
req resource.ImportStateRequest,
@ -493,7 +489,7 @@ func (r *databaseResource) ImportState(
ctx, &resp.Diagnostics,
"Error importing database",
fmt.Sprintf(
"Expected import identifier with format [project_id],[region],[instance_id],[database_id], got %q",
"Expected import identifier with format [project_id],[region],[instance_id],[id], got %q",
req.ID,
),
)
@ -503,73 +499,61 @@ func (r *databaseResource) ImportState(
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), idParts[1])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[2])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("database_id"), idParts[3])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), idParts[3])...) //database id
core.LogAndAddWarning(
ctx,
&resp.Diagnostics,
"Postgresflex database imported with empty password",
"The database password is not imported as it is only available upon creation of a new database. The password field will be empty.",
)
tflog.Info(ctx, "Postgres Flex database state imported")
}
func mapFields(resp *postgresflexalpha.ListDatabase, model *Model, region string) error {
if resp == nil {
return fmt.Errorf("response is nil")
}
if resp.Id == nil || *resp.Id == 0 {
return fmt.Errorf("id not present")
}
if model == nil {
return fmt.Errorf("model input is nil")
var identityData DatabaseResourceIdentityModel
resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...)
if resp.Diagnostics.HasError() {
return
}
var databaseId int64
if model.DatabaseId.ValueInt64() != 0 {
databaseId = model.DatabaseId.ValueInt64()
} else if resp.Id != nil {
databaseId = *resp.Id
} else {
return fmt.Errorf("database id not present")
}
model.Id = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(), region, model.InstanceId.ValueString(), strconv.FormatInt(databaseId, 10),
resp.Diagnostics.Append(
resp.State.SetAttribute(
ctx,
path.Root("tf_id"),
utils.BuildInternalTerraformId(
identityData.ProjectID.ValueString(),
identityData.Region.ValueString(),
identityData.InstanceID.ValueString(),
identityData.DatabaseID.String(),
),
)...,
)
resp.Diagnostics.Append(
resp.State.SetAttribute(
ctx,
path.Root("project_id"),
identityData.ProjectID.ValueString(),
)...,
)
resp.Diagnostics.Append(
resp.State.SetAttribute(
ctx, path.Root("region"), identityData.Region.ValueString(),
)...,
)
resp.Diagnostics.Append(
resp.State.SetAttribute(
ctx,
path.Root("instance_id"),
identityData.InstanceID.ValueString(),
)...,
)
resp.Diagnostics.Append(
resp.State.SetAttribute(
ctx,
path.Root("id"), // database id
identityData.DatabaseID.ValueInt64(),
)...,
)
model.DatabaseId = types.Int64Value(databaseId)
model.Name = types.StringPointerValue(resp.Name)
model.Region = types.StringValue(region)
model.Owner = types.StringPointerValue(cleanString(resp.Owner))
return nil
}
func mapFieldsUpdatePartially(
res *postgresflexalpha.UpdateDatabasePartiallyResponse,
model *Model,
region string,
) error {
if res == nil {
return fmt.Errorf("response is nil")
}
return mapFields(res.Database, model, region)
}
tflog.Info(ctx, "Postgres Flex instance state imported")
func cleanString(s *string) *string {
if s == nil {
return nil
}
res := strings.Trim(*s, "\"")
return &res
}
func toCreatePayload(model *Model) (*postgresflexalpha.CreateDatabaseRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
return &postgresflexalpha.CreateDatabaseRequestPayload{
Name: model.Name.ValueStringPointer(),
Owner: model.Owner.ValueStringPointer(),
}, nil
}
var errDatabaseNotFound = errors.New("database not found")

View file

@ -0,0 +1,539 @@
package postgresflexalpha
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"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/int64planmodifier"
"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"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/pkg_gen/postgresflexalpha"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/conversion"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/core"
postgresflexalpha2 "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/database/datasources_gen"
postgresflexUtils "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/services/postgresflexalpha/utils"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/utils"
"tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/stackit/internal/validate"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &databaseResource{}
_ resource.ResourceWithConfigure = &databaseResource{}
_ resource.ResourceWithImportState = &databaseResource{}
_ resource.ResourceWithModifyPlan = &databaseResource{}
)
type Model struct {
Id types.String `tfsdk:"id"` // needed by TF
DatabaseId types.Int64 `tfsdk:"database_id"`
InstanceId types.String `tfsdk:"instance_id"`
ProjectId types.String `tfsdk:"project_id"`
Name types.String `tfsdk:"name"`
Owner types.String `tfsdk:"owner"`
Region types.String `tfsdk:"region"`
}
// NewDatabaseResource is a helper function to simplify the provider implementation.
func NewDatabaseResource() resource.Resource {
return &databaseResource{}
}
// databaseResource is the resource implementation.
type databaseResource struct {
client *postgresflexalpha.APIClient
providerData core.ProviderData
}
// ModifyPlan implements resource.ResourceWithModifyPlan.
// Use the modifier to set the effective region in the current plan.
func (r *databaseResource) 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 *databaseResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgresflexalpha_database"
}
// Configure adds the provider configured client to the resource.
func (r *databaseResource) Configure(
ctx context.Context,
req resource.ConfigureRequest,
resp *resource.ConfigureResponse,
) {
var ok bool
r.providerData, ok = conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
if !ok {
return
}
apiClient := postgresflexUtils.ConfigureClient(ctx, &r.providerData, &resp.Diagnostics)
if resp.Diagnostics.HasError() {
return
}
r.client = apiClient
tflog.Info(ctx, "Postgres Flex database client configured")
}
// Schema defines the schema for the resource.
func (r *databaseResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
descriptions := map[string]string{
"main": "Postgres Flex database resource schema. Must have a `region` specified in the provider configuration.",
"id": "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_id`,`database_id`\".",
"database_id": "Database ID.",
"instance_id": "ID of the Postgres Flex instance.",
"project_id": "STACKIT project ID to which the instance is associated.",
"name": "Database name.",
"owner": "Username of the database owner.",
"region": "The resource region. If not defined, the provider region is used.",
}
resp.Schema = schema.Schema{
Description: descriptions["main"],
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Description: descriptions["id"],
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"database_id": schema.Int64Attribute{
Description: descriptions["database_id"],
Computed: true,
PlanModifiers: []planmodifier.Int64{
int64planmodifier.UseStateForUnknown(),
},
Validators: []validator.Int64{},
},
"instance_id": schema.StringAttribute{
Description: descriptions["instance_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"project_id": schema.StringAttribute{
Description: descriptions["project_id"],
Required: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
Validators: []validator.String{
validate.UUID(),
validate.NoSeparator(),
},
},
"name": schema.StringAttribute{
Description: descriptions["name"],
Required: true,
PlanModifiers: []planmodifier.String{},
Validators: []validator.String{
stringvalidator.RegexMatches(
regexp.MustCompile("^[a-z]([a-z0-9]*)?$"),
"must start with a letter, must have lower case letters or numbers",
),
},
},
"owner": schema.StringAttribute{
Description: descriptions["owner"],
Required: true,
PlanModifiers: []planmodifier.String{},
},
"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(),
},
},
},
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *databaseResource) 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 := model.Region.ValueString()
instanceId := model.InstanceId.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "region", region)
// Generate API request body from model
payload, err := toCreatePayload(&model)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating database",
fmt.Sprintf("Creating API payload: %v", err),
)
return
}
// Create new database
databaseResp, err := r.client.CreateDatabaseRequest(
ctx,
projectId,
region,
instanceId,
).CreateDatabaseRequestPayload(*payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
if databaseResp == nil || databaseResp.Id == nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating database",
"API didn't return database Id. A database might have been created",
)
return
}
databaseId := *databaseResp.Id
ctx = tflog.SetField(ctx, "database_id", databaseId)
database, err := getDatabaseById(ctx, r.client, projectId, region, instanceId, databaseId)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating database",
fmt.Sprintf("Getting database details after creation: %v", err),
)
return
}
// Map response body to schema
err = mapFields(database, &model, region)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error creating database",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database created")
}
// Read refreshes the Terraform state with the latest data.
func (r *databaseResource) 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()
instanceId := model.InstanceId.ValueString()
databaseId := model.DatabaseId.ValueInt64()
region := r.providerData.GetRegionWithOverride(model.Region)
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = tflog.SetField(ctx, "region", region)
databaseResp, err := getDatabaseById(ctx, r.client, projectId, region, instanceId, databaseId)
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if (ok && oapiErr.StatusCode == http.StatusNotFound) || errors.Is(err, errDatabaseNotFound) {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading database", fmt.Sprintf("Calling API: %v", err))
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFields(databaseResp, &model, region)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error reading database",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
// Set refreshed state
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database read")
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *databaseResource) Update(
ctx context.Context,
req resource.UpdateRequest,
resp *resource.UpdateResponse,
) {
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()
instanceId := model.InstanceId.ValueString()
databaseId64 := model.DatabaseId.ValueInt64()
if databaseId64 > math.MaxInt32 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error in type conversion", "int value too large (databaseId)")
return
}
databaseId := int32(databaseId64)
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = tflog.SetField(ctx, "region", region)
// Retrieve values from state
var stateModel Model
diags = req.State.Get(ctx, &stateModel)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
modified := false
var payload postgresflexalpha.UpdateDatabasePartiallyRequestPayload
if stateModel.Name != model.Name {
payload.Name = model.Name.ValueStringPointer()
modified = true
}
if stateModel.Owner != model.Owner {
payload.Owner = model.Owner.ValueStringPointer()
modified = true
}
if !modified {
tflog.Info(ctx, "no modification detected")
return
}
// Update existing database
res, err := r.client.UpdateDatabasePartiallyRequest(
ctx,
projectId,
region,
instanceId,
databaseId,
).UpdateDatabasePartiallyRequestPayload(payload).Execute()
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "error updating database", err.Error())
return
}
ctx = core.LogResponse(ctx)
// Map response body to schema
err = mapFieldsUpdatePartially(res, &model, region)
if err != nil {
core.LogAndAddError(
ctx,
&resp.Diagnostics,
"Error updating database",
fmt.Sprintf("Processing API payload: %v", err),
)
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Postgres Flex database updated")
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *databaseResource) 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()
instanceId := model.InstanceId.ValueString()
databaseId64 := model.DatabaseId.ValueInt64()
if databaseId64 > math.MaxInt32 {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error in type conversion", "int value too large (databaseId)")
return
}
databaseId := int32(databaseId64)
region := model.Region.ValueString()
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "instance_id", instanceId)
ctx = tflog.SetField(ctx, "database_id", databaseId)
ctx = tflog.SetField(ctx, "region", region)
// Delete existing record set
err := r.client.DeleteDatabaseRequestExecute(ctx, projectId, region, instanceId, databaseId)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting database", fmt.Sprintf("Calling API: %v", err))
}
ctx = core.LogResponse(ctx)
tflog.Info(ctx, "Postgres Flex database deleted")
}
// ImportState imports a resource into the Terraform state on success.
// The expected format of the resource import identifier is: project_id,zone_id,record_set_id
func (r *databaseResource) ImportState(
ctx context.Context,
req resource.ImportStateRequest,
resp *resource.ImportStateResponse,
) {
idParts := strings.Split(req.ID, core.Separator)
if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
core.LogAndAddError(
ctx, &resp.Diagnostics,
"Error importing database",
fmt.Sprintf(
"Expected import identifier with format [project_id],[region],[instance_id],[database_id], got %q",
req.ID,
),
)
return
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), idParts[0])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("region"), idParts[1])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("instance_id"), idParts[2])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("database_id"), idParts[3])...)
core.LogAndAddWarning(
ctx,
&resp.Diagnostics,
"Postgresflex database imported with empty password",
"The database password is not imported as it is only available upon creation of a new database. The password field will be empty.",
)
tflog.Info(ctx, "Postgres Flex database state imported")
}
func mapFieldsUpdatePartially(
res *postgresflexalpha.UpdateDatabasePartiallyResponse,
model *postgresflexalpha2.DatabaseModel,
region string,
) error {
if res == nil {
return fmt.Errorf("response is nil")
}
return mapFields(res.Database, model, region)
}
func toCreatePayload(model *Model) (*postgresflexalpha.CreateDatabaseRequestPayload, error) {
if model == nil {
return nil, fmt.Errorf("nil model")
}
return &postgresflexalpha.CreateDatabaseRequestPayload{
Name: model.Name.ValueStringPointer(),
Owner: model.Owner.ValueStringPointer(),
}, nil
}
var errDatabaseNotFound = errors.New("database not found")

View file

@ -1,232 +0,0 @@
package postgresflexalpha
import (
"reflect"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/stackitcloud/stackit-sdk-go/core/utils"
postgresflex "tf-provider.git.onstackit.cloud/stackit-dev-tools/terraform-provider-stackitprivatepreview/pkg_gen/postgresflexalpha"
)
func TestMapFields(t *testing.T) {
const testRegion = "region"
tests := []struct {
description string
input *postgresflex.ListDatabase
region string
expected Model
isValid bool
}{
{
"default_values",
&postgresflex.ListDatabase{
Id: utils.Ptr(int64(1)),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,1"),
DatabaseId: types.Int64Value(int64(1)),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringNull(),
Owner: types.StringNull(),
Region: types.StringValue(testRegion),
},
true,
},
{
"simple_values",
&postgresflex.ListDatabase{
Id: utils.Ptr(int64(1)),
Name: utils.Ptr("dbname"),
Owner: utils.Ptr("username"),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,1"),
DatabaseId: types.Int64Value(int64(1)),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue("dbname"),
Owner: types.StringValue("username"),
Region: types.StringValue(testRegion),
},
true,
},
{
"null_fields_and_int_conversions",
&postgresflex.ListDatabase{
Id: utils.Ptr(int64(1)),
Name: utils.Ptr(""),
Owner: utils.Ptr(""),
},
testRegion,
Model{
Id: types.StringValue("pid,region,iid,1"),
DatabaseId: types.Int64Value(int64(1)),
InstanceId: types.StringValue("iid"),
ProjectId: types.StringValue("pid"),
Name: types.StringValue(""),
Owner: types.StringValue(""),
Region: types.StringValue(testRegion),
},
true,
},
{
"nil_response",
nil,
testRegion,
Model{},
false,
},
{
"empty_response",
&postgresflex.ListDatabase{},
testRegion,
Model{},
false,
},
{
"no_resource_id",
&postgresflex.ListDatabase{
Id: utils.Ptr(int64(0)),
Name: utils.Ptr("dbname"),
Owner: utils.Ptr("username"),
},
testRegion,
Model{},
false,
},
}
for _, tt := range tests {
t.Run(
tt.description, func(t *testing.T) {
state := &Model{
ProjectId: tt.expected.ProjectId,
InstanceId: tt.expected.InstanceId,
}
err := mapFields(tt.input, state, tt.region)
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 *postgresflex.CreateDatabaseRequestPayload
isValid bool
}{
{
"default_values",
&Model{
Name: types.StringValue("dbname"),
Owner: types.StringValue("username"),
},
&postgresflex.CreateDatabaseRequestPayload{
Name: utils.Ptr("dbname"),
Owner: utils.Ptr("username"),
},
true,
},
{
"null_fields",
&Model{
Name: types.StringNull(),
Owner: types.StringNull(),
},
&postgresflex.CreateDatabaseRequestPayload{
Name: nil,
Owner: nil,
},
true,
},
{
"nil_model",
nil,
nil,
false,
},
}
for _, tt := range tests {
t.Run(
tt.description, func(t *testing.T) {
output, err := toCreatePayload(tt.input)
if !tt.isValid && err == nil {
t.Fatalf("Should have failed")
}
if tt.isValid && err != nil {
t.Fatalf("Should not have failed: %v", err)
}
if tt.isValid {
diff := cmp.Diff(output, tt.expected)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
}
},
)
}
}
func Test_cleanString(t *testing.T) {
type args struct {
s *string
}
tests := []struct {
name string
args args
want *string
}{
{
name: "simple_value",
args: args{
s: utils.Ptr("mytest"),
},
want: utils.Ptr("mytest"),
},
{
name: "simple_value_with_quotes",
args: args{
s: utils.Ptr("\"mytest\""),
},
want: utils.Ptr("mytest"),
},
{
name: "simple_values_with_quotes",
args: args{
s: utils.Ptr("\"my test here\""),
},
want: utils.Ptr("my test here"),
},
{
name: "simple_values",
args: args{
s: utils.Ptr("my test here"),
},
want: utils.Ptr("my test here"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := cleanString(tt.args.s); !reflect.DeepEqual(got, tt.want) {
t.Errorf("cleanString() = %v, want %v", got, tt.want)
}
})
}
}