Files
tf_provider/internal/provider/postgres_resource.go
T

1047 lines
36 KiB
Go

package provider
import (
"bytes"
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"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/int64default"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"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"
)
var _ resource.Resource = &PostgresResource{}
var _ resource.ResourceWithImportState = &PostgresResource{}
var _ resource.ResourceWithModifyPlan = &PostgresResource{}
type PostgresResource struct {
client *NubesClient
}
var uuidLikeRegex = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
func isUUIDLike(value string) bool {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return false
}
return uuidLikeRegex.MatchString(trimmed)
}
func (r *PostgresResource) resolveS3Uid(ctx context.Context, value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", nil
}
if isUUIDLike(trimmed) {
return trimmed, nil
}
return "", fmt.Errorf("s3_uid must be a UUID, got: %s", trimmed)
}
func NewPostgresResource() resource.Resource {
return &PostgresResource{}
}
type PostgresResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
ResourceRealm types.String `tfsdk:"resource_realm"`
S3Uid types.String `tfsdk:"s3_uid"`
Cpu types.Int64 `tfsdk:"cpu"`
Ram types.Int64 `tfsdk:"ram"`
DiskSize types.Int64 `tfsdk:"disk_size"`
NodesCount types.Int64 `tfsdk:"nodes_count"`
Version types.String `tfsdk:"version"`
BackupSchedule types.String `tfsdk:"backup_schedule"`
BackupRetention types.Int64 `tfsdk:"backup_retention"`
Parameters types.String `tfsdk:"parameters"`
EnablePgPoolerMaster types.Bool `tfsdk:"enable_pgpooler_master"`
EnablePgPoolerSlave types.Bool `tfsdk:"enable_pgpooler_slave"`
AllowNoSSL types.Bool `tfsdk:"allow_no_ssl"`
AutoScale types.Bool `tfsdk:"auto_scale"`
AutoScalePercentage types.Int64 `tfsdk:"auto_scale_percentage"`
AutoScaleTechWindow types.Int64 `tfsdk:"auto_scale_tech_window"`
AutoScaleQuotaGb types.Int64 `tfsdk:"auto_scale_quota_gb"`
EnableExternalMaster types.Bool `tfsdk:"enable_external_master"`
EnableExternalSlave types.Bool `tfsdk:"enable_external_slave"`
IpSpaceMaster types.String `tfsdk:"ip_space_master"`
IpSpaceSlave types.String `tfsdk:"ip_space_slave"`
DeletionProtection types.Bool `tfsdk:"deletion_protection"`
// Computed
AdminUser types.String `tfsdk:"admin_user"`
AdminPassword types.String `tfsdk:"admin_password"`
StandbyUser types.String `tfsdk:"standby_user"`
StandbyPassword types.String `tfsdk:"standby_password"`
InternalHost types.String `tfsdk:"internal_host"`
VaultUserPath types.String `tfsdk:"vault_user_path"`
VaultUrl types.String `tfsdk:"vault_url"`
MonitoringUrl types.String `tfsdk:"monitoring_url"`
InternalConnectMaster types.String `tfsdk:"internal_connect_master"`
InternalConnectSlave types.String `tfsdk:"internal_connect_slave"`
ExternalConnectMasterIp types.String `tfsdk:"external_connect_master_ip"`
ExternalConnectMasterFqdn types.String `tfsdk:"external_connect_master_fqdn"`
ExternalConnectSlaveIp types.String `tfsdk:"external_connect_slave_ip"`
ExternalConnectSlaveFqdn types.String `tfsdk:"external_connect_slave_fqdn"`
}
func (r *PostgresResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_postgres"
}
func (r *PostgresResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Manages a Postgres cluster.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"name": schema.StringAttribute{
Required: true,
Description: "Name of the Postgres instance",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"resource_realm": schema.StringAttribute{
Required: true,
Description: "Deployment platform (param 102). Example: k8s-3.ext.nubes.ru",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"s3_uid": schema.StringAttribute{
Required: true,
Description: "UID Корневой услуги S3 (Param 23). Только UUID, без displayName. Пример: 6d6061cb-b0c1-44b9-8969-a70f08fe673c",
Validators: []validator.String{
UUIDLike(),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"cpu": schema.Int64Attribute{
Required: true,
Description: "CPU in millicores (param 82)",
},
"ram": schema.Int64Attribute{
Required: true,
Description: "Memory in MB (param 81)",
},
"disk_size": schema.Int64Attribute{
Required: true,
Description: "Disk size in GB (param 83)",
},
"nodes_count": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(1),
Description: "Number of instances (param 80)",
},
"version": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("17"),
Description: "Postgres Version (param 310)",
},
"backup_schedule": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("0 0 * * *"),
Description: "Backup Schedule (param 265)",
},
"backup_retention": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(7),
Description: "Backup Retention Days (param 266)",
},
"parameters": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("{}"),
Description: "Postgres config as JSON string (param 311)",
},
"enable_pgpooler_master": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
Description: "Enable pgPooler for Master (param 312)",
},
"enable_pgpooler_slave": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
Description: "Enable pgPooler for Slave (param 313)",
},
"allow_no_ssl": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
Description: "Allow No SSL (param 314)",
},
"auto_scale": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
Description: "Enable AutoScale (param 329)",
},
"auto_scale_percentage": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(10),
Description: "AutoScale Percentage (param 330)",
},
"auto_scale_tech_window": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(0),
Description: "AutoScale Tech Window (param 331)",
},
"auto_scale_quota_gb": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(1),
Description: "AutoScale Quota GB (param 339)",
},
"enable_external_master": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
Description: "Need External IP for Master (param 145)",
},
"enable_external_slave": schema.BoolAttribute{
Optional: true,
Computed: true,
Default: booldefault.StaticBool(false),
Description: "Need External IP for Slave (param 146)",
},
"ip_space_master": schema.StringAttribute{
Optional: true,
Description: "IP Space Name for Master (param 337)",
},
"ip_space_slave": schema.StringAttribute{
Optional: true,
Description: "IP Space Name for Slave (param 338)",
},
"deletion_protection": schema.BoolAttribute{
MarkdownDescription: "If true, the resource will only be removed from Terraform state upon destroy, but will remain in the cloud. If false, destroy will trigger 'suspend' in Nubes.",
Optional: true,
Computed: true,
Default: booldefault.StaticBool(true),
},
// Output
"admin_user": schema.StringAttribute{
Computed: true,
},
"admin_password": schema.StringAttribute{
Computed: true,
Sensitive: true,
},
"standby_user": schema.StringAttribute{
Computed: true,
},
"standby_password": schema.StringAttribute{
Computed: true,
Sensitive: true,
},
"internal_host": schema.StringAttribute{
Computed: true,
},
"vault_user_path": schema.StringAttribute{
Computed: true,
},
"vault_url": schema.StringAttribute{
Computed: true,
},
"monitoring_url": schema.StringAttribute{
Computed: true,
},
"internal_connect_master": schema.StringAttribute{
Computed: true,
},
"internal_connect_slave": schema.StringAttribute{
Computed: true,
},
"external_connect_master_ip": schema.StringAttribute{
Computed: true,
},
"external_connect_master_fqdn": schema.StringAttribute{
Computed: true,
},
"external_connect_slave_ip": schema.StringAttribute{
Computed: true,
},
"external_connect_slave_fqdn": schema.StringAttribute{
Computed: true,
},
},
}
}
func (r *PostgresResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*NubesClient)
if !ok {
resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected *NubesClient, got: %T", req.ProviderData))
return
}
r.client = client
}
func (r *PostgresResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan PostgresResourceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Creating Postgres resource...")
// Attempt to resume suspended instance if exists
tflog.Info(ctx, "Checking for suspended instances to resume...")
var resumedInstanceUid string
instances, err := r.client.GetInstances(ctx)
if err != nil {
tflog.Warn(ctx, fmt.Sprintf("Failed to list instances: %s", err))
} else {
tflog.Info(ctx, fmt.Sprintf("Scanned %d instances.", len(instances)))
for _, inst := range instances {
if inst.DisplayName == plan.Name.ValueString() && inst.ServiceId == 90 {
tflog.Info(ctx, fmt.Sprintf("Found matching instance name: %s (%s)", inst.DisplayName, inst.InstanceUid))
details, err := r.client.GetInstanceStateDetails(ctx, inst.InstanceUid)
if err == nil {
isSuspended, _ := details["isSuspended"].(bool)
isDeleted, _ := details["isDeleted"].(bool)
tflog.Info(ctx, fmt.Sprintf("Instance status isSuspended: %v, isDeleted: %v", isSuspended, isDeleted))
if isSuspended {
tflog.Info(ctx, fmt.Sprintf("Found suspended instance %s. Resuming...", inst.InstanceUid))
if err := r.client.RunAction(ctx, inst.InstanceUid, "resume"); err != nil {
resp.Diagnostics.AddError("Resume Failed", err.Error())
return
}
// Postgres goes to 'running' state, not 'active'
if err := r.client.WaitForInstanceStatus(ctx, inst.InstanceUid, "running"); err != nil {
resp.Diagnostics.AddError("Wait for Resume Failed", err.Error())
return
}
resumedInstanceUid = inst.InstanceUid
} else if !isDeleted {
tflog.Info(ctx, fmt.Sprintf("Instance %s is already active. Adopting...", inst.InstanceUid))
resumedInstanceUid = inst.InstanceUid
}
}
break
}
}
}
resolvedS3Uid, err := r.resolveS3Uid(ctx, plan.S3Uid.ValueString())
if err != nil {
resp.Diagnostics.AddError("Failed to resolve s3_uid", err.Error())
return
}
if resolvedS3Uid != "" {
plan.S3Uid = types.StringValue(resolvedS3Uid)
}
params := []InstanceParam{
{SvcOperationCfsParamId: 102, ParamValue: plan.ResourceRealm.ValueString()},
{SvcOperationCfsParamId: 23, ParamValue: plan.S3Uid.ValueString()},
{SvcOperationCfsParamId: 82, ParamValue: fmt.Sprintf("%d", plan.Cpu.ValueInt64())},
{SvcOperationCfsParamId: 81, ParamValue: fmt.Sprintf("%d", plan.Ram.ValueInt64())},
{SvcOperationCfsParamId: 83, ParamValue: fmt.Sprintf("%d", plan.DiskSize.ValueInt64())},
{SvcOperationCfsParamId: 80, ParamValue: fmt.Sprintf("%d", plan.NodesCount.ValueInt64())},
{SvcOperationCfsParamId: 266, ParamValue: fmt.Sprintf("%d", plan.BackupRetention.ValueInt64())},
{SvcOperationCfsParamId: 265, ParamValue: plan.BackupSchedule.ValueString()},
{SvcOperationCfsParamId: 310, ParamValue: plan.Version.ValueString()},
{SvcOperationCfsParamId: 311, ParamValue: plan.Parameters.ValueString()},
{SvcOperationCfsParamId: 312, ParamValue: strconv.FormatBool(plan.EnablePgPoolerMaster.ValueBool())},
{SvcOperationCfsParamId: 313, ParamValue: strconv.FormatBool(plan.EnablePgPoolerSlave.ValueBool())},
{SvcOperationCfsParamId: 314, ParamValue: strconv.FormatBool(plan.AllowNoSSL.ValueBool())},
{SvcOperationCfsParamId: 329, ParamValue: strconv.FormatBool(plan.AutoScale.ValueBool())},
{SvcOperationCfsParamId: 330, ParamValue: fmt.Sprintf("%d", plan.AutoScalePercentage.ValueInt64())},
{SvcOperationCfsParamId: 331, ParamValue: fmt.Sprintf("%d", plan.AutoScaleTechWindow.ValueInt64())},
{SvcOperationCfsParamId: 339, ParamValue: fmt.Sprintf("%d", plan.AutoScaleQuotaGb.ValueInt64())},
{SvcOperationCfsParamId: 145, ParamValue: strconv.FormatBool(plan.EnableExternalMaster.ValueBool())},
{SvcOperationCfsParamId: 146, ParamValue: strconv.FormatBool(plan.EnableExternalSlave.ValueBool())},
}
if !plan.IpSpaceMaster.IsNull() {
params = append(params, InstanceParam{SvcOperationCfsParamId: 337, ParamValue: plan.IpSpaceMaster.ValueString()})
} else {
params = append(params, InstanceParam{SvcOperationCfsParamId: 337, ParamValue: ""})
}
if !plan.IpSpaceSlave.IsNull() {
params = append(params, InstanceParam{SvcOperationCfsParamId: 338, ParamValue: plan.IpSpaceSlave.ValueString()})
} else {
params = append(params, InstanceParam{SvcOperationCfsParamId: 338, ParamValue: ""})
}
serviceId := 90
var instanceUid string
if resumedInstanceUid != "" {
instanceUid = resumedInstanceUid
} else {
svcOperationId, err := r.client.GetOperationId(ctx, serviceId, "create")
if err != nil {
resp.Diagnostics.AddError("Failed to get create operation ID", err.Error())
return
}
displayName := plan.Name.ValueString()
var opUid string
instanceUid, opUid, err = r.client.CreateInstance(ctx, displayName, serviceId, svcOperationId, params)
if err != nil {
resp.Diagnostics.AddError("Error creating Postgres", err.Error())
return
}
// Wait for operation success
err = r.client.WaitForOperation(ctx, opUid)
if err != nil {
resp.Diagnostics.AddError("Error waiting for Postgres creation", err.Error())
return
}
}
plan.ID = types.StringValue(instanceUid)
// Initialize computed fields to avoid "Unknown" errors if API doesn't return them
plan.AdminUser = types.StringNull()
plan.AdminPassword = types.StringNull()
plan.StandbyUser = types.StringNull()
plan.StandbyPassword = types.StringNull()
plan.InternalHost = types.StringNull()
plan.VaultUserPath = types.StringNull()
plan.VaultUrl = types.StringNull()
plan.MonitoringUrl = types.StringNull()
plan.InternalConnectMaster = types.StringNull()
plan.InternalConnectSlave = types.StringNull()
plan.ExternalConnectMasterIp = types.StringNull()
plan.ExternalConnectMasterFqdn = types.StringNull()
plan.ExternalConnectSlaveIp = types.StringNull()
plan.ExternalConnectSlaveFqdn = types.StringNull()
// Fetch details to populate Computed fields
stateData, err := r.client.GetInstanceStateDetails(ctx, instanceUid)
if err != nil {
resp.Diagnostics.AddWarning("Failed to populate computed fields", err.Error())
} else {
// Vault
if vault, ok := stateData["vault"].(map[string]interface{}); ok {
if val, ok := vault["userPath"]; ok {
plan.VaultUserPath = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := vault["url"]; ok {
plan.VaultUrl = types.StringValue(fmt.Sprintf("%v", val))
}
}
// Out (Connect details)
if out, ok := stateData["out"].(map[string]interface{}); ok {
// Credentials
if val, ok := out["adminUser"]; ok {
plan.AdminUser = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := out["adminPass"]; ok {
plan.AdminPassword = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := out["standbyUser"]; ok {
plan.StandbyUser = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := out["standbyPass"]; ok {
plan.StandbyPassword = types.StringValue(fmt.Sprintf("%v", val))
}
// Monitoring
if monitoring, ok := out["monitoring"].(map[string]interface{}); ok {
if val, ok := monitoring["allDashboards"]; ok {
plan.MonitoringUrl = types.StringValue(fmt.Sprintf("%v", val))
}
}
// Internal Connect
if internalConnect, ok := out["internalConnect"].(map[string]interface{}); ok {
if val, ok := internalConnect["master"]; ok && val != "" {
plan.InternalConnectMaster = types.StringValue(fmt.Sprintf("%v", val))
plan.InternalHost = types.StringValue(fmt.Sprintf("%v", val)) // Sync with master for now
}
if val, ok := internalConnect["slave"]; ok && val != "" {
plan.InternalConnectSlave = types.StringValue(fmt.Sprintf("%v", val))
}
}
// External Connect
if externalConnect, ok := out["externalConnect"].(map[string]interface{}); ok {
if master, ok := externalConnect["master"].(map[string]interface{}); ok {
if val, ok := master["ip"]; ok && val != "" {
plan.ExternalConnectMasterIp = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := master["fqdn"]; ok && val != "" {
plan.ExternalConnectMasterFqdn = types.StringValue(fmt.Sprintf("%v", val))
}
}
if slave, ok := externalConnect["slave"].(map[string]interface{}); ok {
if val, ok := slave["ip"]; ok && val != "" {
plan.ExternalConnectSlaveIp = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := slave["fqdn"]; ok && val != "" {
plan.ExternalConnectSlaveFqdn = types.StringValue(fmt.Sprintf("%v", val))
}
}
}
}
}
diags = resp.State.Set(ctx, plan)
resp.Diagnostics.Append(diags...)
}
func (r *PostgresResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var state PostgresResourceModel
diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
if !state.S3Uid.IsNull() && !state.S3Uid.IsUnknown() {
resolvedS3Uid, err := r.resolveS3Uid(ctx, state.S3Uid.ValueString())
if err == nil && resolvedS3Uid != "" {
state.S3Uid = types.StringValue(resolvedS3Uid)
} else if err != nil {
resp.Diagnostics.AddWarning("Failed to resolve s3_uid", err.Error())
}
}
stateData, err := r.client.GetInstanceStateDetails(ctx, state.ID.ValueString())
if err != nil {
resp.Diagnostics.AddError("Error reading Postgres", err.Error())
return
}
// Update calculated fields
// Vault
if vault, ok := stateData["vault"].(map[string]interface{}); ok {
if val, ok := vault["userPath"]; ok {
state.VaultUserPath = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := vault["url"]; ok {
state.VaultUrl = types.StringValue(fmt.Sprintf("%v", val))
}
}
// Out
if out, ok := stateData["out"].(map[string]interface{}); ok {
// Credentials
if val, ok := out["adminUser"]; ok {
state.AdminUser = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := out["adminPass"]; ok {
state.AdminPassword = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := out["standbyUser"]; ok {
state.StandbyUser = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := out["standbyPass"]; ok {
state.StandbyPassword = types.StringValue(fmt.Sprintf("%v", val))
}
// Monitoring
if monitoring, ok := out["monitoring"].(map[string]interface{}); ok {
if val, ok := monitoring["allDashboards"]; ok {
state.MonitoringUrl = types.StringValue(fmt.Sprintf("%v", val))
}
}
// Internal Connect
if internalConnect, ok := out["internalConnect"].(map[string]interface{}); ok {
if val, ok := internalConnect["master"]; ok && val != "" {
state.InternalConnectMaster = types.StringValue(fmt.Sprintf("%v", val))
state.InternalHost = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := internalConnect["slave"]; ok && val != "" {
state.InternalConnectSlave = types.StringValue(fmt.Sprintf("%v", val))
}
}
// External Connect
if externalConnect, ok := out["externalConnect"].(map[string]interface{}); ok {
if master, ok := externalConnect["master"].(map[string]interface{}); ok {
if val, ok := master["ip"]; ok && val != "" {
state.ExternalConnectMasterIp = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := master["fqdn"]; ok && val != "" {
state.ExternalConnectMasterFqdn = types.StringValue(fmt.Sprintf("%v", val))
}
}
if slave, ok := externalConnect["slave"].(map[string]interface{}); ok {
if val, ok := slave["ip"]; ok && val != "" {
state.ExternalConnectSlaveIp = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := slave["fqdn"]; ok && val != "" {
state.ExternalConnectSlaveFqdn = types.StringValue(fmt.Sprintf("%v", val))
}
}
}
}
diags = resp.State.Set(ctx, &state)
resp.Diagnostics.Append(diags...)
}
func (r *PostgresResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan, state PostgresResourceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
diags = req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Info(ctx, "Updating Postgres resource...")
// Validation: Verify Disk Size reduction
if plan.DiskSize.ValueInt64() < state.DiskSize.ValueInt64() {
resp.Diagnostics.AddError(
"Invalid Disk Upgrade",
fmt.Sprintf("Decreasing disk size is not supported (Current: %d GB, Requested: %d GB)", state.DiskSize.ValueInt64(), plan.DiskSize.ValueInt64()),
)
return
}
// Find operation ID for 'modify'
opId, err := r.client.GetOperationIdForInstance(ctx, state.ID.ValueString(), "modify")
if err != nil {
resp.Diagnostics.AddError("Failed to find update operation", err.Error())
return
}
// Create Operation
opPayload := map[string]interface{}{
"instanceUid": state.ID.ValueString(),
"svcOperationId": opId,
"operation": "modify",
}
var opUid string
opUid, err = r.client.postIgnoreResponse(ctx, "/instanceOperations", opPayload, true)
if err != nil {
resp.Diagnostics.AddError("Failed to create update operation", err.Error())
return
}
if opUid == "" {
resp.Diagnostics.AddError("Failed to get operation UID", "Empty UID returned")
return
}
// Fetch Op details to get correct CFS Param IDs for 'modify'
opDetails, err := r.client.GetInstanceOperation(ctx, opUid)
if err != nil {
resp.Diagnostics.AddError("Failed to fetch operation details", err.Error())
return
}
paramMapping := make(map[string]int)
for _, p := range opDetails.CfsParams {
paramMapping[p.SvcOperationCfsParam] = p.SvcOperationCfsParamId
}
type NamedParam struct {
Name string
Value string
}
namedParams := []NamedParam{
{"resourceCPU", fmt.Sprintf("%d", plan.Cpu.ValueInt64())},
{"resourceMemory", fmt.Sprintf("%d", plan.Ram.ValueInt64())},
{"resourceDisk", fmt.Sprintf("%d", plan.DiskSize.ValueInt64())},
{"resourceInstances", fmt.Sprintf("%d", plan.NodesCount.ValueInt64())},
{"ext_BACKUP_NUM_TO_RETAIN", fmt.Sprintf("%d", plan.BackupRetention.ValueInt64())},
{"ext_BACKUP_SCHEDULE", plan.BackupSchedule.ValueString()},
{"appVersion", plan.Version.ValueString()},
{"jsonParameters", plan.Parameters.ValueString()},
{"enablePgPoolerMaster", strconv.FormatBool(plan.EnablePgPoolerMaster.ValueBool())},
{"enablePgPoolerSlave", strconv.FormatBool(plan.EnablePgPoolerSlave.ValueBool())},
{"allowNoSSL", strconv.FormatBool(plan.AllowNoSSL.ValueBool())},
{"autoScale", strconv.FormatBool(plan.AutoScale.ValueBool())},
{"autoScalePercentage", fmt.Sprintf("%d", plan.AutoScalePercentage.ValueInt64())},
{"autoScaleTechWindow", fmt.Sprintf("%d", plan.AutoScaleTechWindow.ValueInt64())},
{"autoScaleQuotaGb", fmt.Sprintf("%d", plan.AutoScaleQuotaGb.ValueInt64())},
{"needExternalAddressMaster", strconv.FormatBool(plan.EnableExternalMaster.ValueBool())},
{"needExternalAddressSlave", strconv.FormatBool(plan.EnableExternalSlave.ValueBool())},
}
if !plan.IpSpaceMaster.IsNull() {
namedParams = append(namedParams, NamedParam{"ipSpaceNameMaster", plan.IpSpaceMaster.ValueString()})
} else {
namedParams = append(namedParams, NamedParam{"ipSpaceNameMaster", ""})
}
if !plan.IpSpaceSlave.IsNull() {
namedParams = append(namedParams, NamedParam{"ipSpaceNameSlave", plan.IpSpaceSlave.ValueString()})
} else {
namedParams = append(namedParams, NamedParam{"ipSpaceNameSlave", ""})
}
// Add Params using dynamic mapping
for _, np := range namedParams {
id, ok := paramMapping[np.Name]
if !ok {
tflog.Debug(ctx, fmt.Sprintf("Param %s not supported for this operation, skipping", np.Name))
continue
}
paramPayload := map[string]interface{}{
"instanceOperationUid": opUid,
"svcOperationCfsParamId": id,
"paramValue": np.Value,
}
if _, err := r.client.postIgnoreResponse(ctx, "/instanceOperationCfsParams", paramPayload, false); err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to add param %s (%d)", np.Name, id), err.Error())
return
}
}
// Run
runUrl := fmt.Sprintf("/instanceOperations/%s/run", opUid)
if _, err := r.client.postIgnoreResponse(ctx, runUrl, map[string]interface{}{}, false); err != nil {
resp.Diagnostics.AddError("Failed to run update", err.Error())
return
}
// Wait for operation success
err = r.client.WaitForOperation(ctx, opUid)
if err != nil {
resp.Diagnostics.AddError("Error waiting for Postgres update", err.Error())
return
}
// Fetch details to populate Computed fields
stateData, err := r.client.GetInstanceStateDetails(ctx, state.ID.ValueString())
if err != nil {
tflog.Warn(ctx, fmt.Sprintf("Failed to refresh state after update: %s", err))
}
// Initialize all computed fields to avoid 'unknown value' errors after apply
plan.AdminUser = types.StringNull()
plan.AdminPassword = types.StringNull()
plan.StandbyUser = types.StringNull()
plan.StandbyPassword = types.StringNull()
plan.VaultUserPath = types.StringNull()
plan.VaultUrl = types.StringNull()
plan.MonitoringUrl = types.StringNull()
plan.InternalConnectMaster = types.StringNull()
plan.InternalHost = types.StringNull()
plan.InternalConnectSlave = types.StringNull()
plan.ExternalConnectMasterIp = types.StringNull()
plan.ExternalConnectMasterFqdn = types.StringNull()
plan.ExternalConnectSlaveIp = types.StringNull()
plan.ExternalConnectSlaveFqdn = types.StringNull()
if err == nil {
// Vault
if vault, ok := stateData["vault"].(map[string]interface{}); ok {
if val, ok := vault["userPath"]; ok {
plan.VaultUserPath = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := vault["url"]; ok {
plan.VaultUrl = types.StringValue(fmt.Sprintf("%v", val))
}
}
// Out (Connect details)
if out, ok := stateData["out"].(map[string]interface{}); ok {
// Credentials
if val, ok := out["adminUser"]; ok {
plan.AdminUser = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := out["adminPass"]; ok {
plan.AdminPassword = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := out["standbyUser"]; ok {
plan.StandbyUser = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := out["standbyPass"]; ok {
plan.StandbyPassword = types.StringValue(fmt.Sprintf("%v", val))
}
// Monitoring
if monitoring, ok := out["monitoring"].(map[string]interface{}); ok {
if val, ok := monitoring["allDashboards"]; ok {
plan.MonitoringUrl = types.StringValue(fmt.Sprintf("%v", val))
}
}
// Internal Connect
if internalConnect, ok := out["internalConnect"].(map[string]interface{}); ok {
if val, ok := internalConnect["master"]; ok && val != "" {
plan.InternalConnectMaster = types.StringValue(fmt.Sprintf("%v", val))
plan.InternalHost = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := internalConnect["slave"]; ok && val != "" {
plan.InternalConnectSlave = types.StringValue(fmt.Sprintf("%v", val))
}
}
// External Connect
if externalConnect, ok := out["externalConnect"].(map[string]interface{}); ok {
if master, ok := externalConnect["master"].(map[string]interface{}); ok {
if val, ok := master["ip"]; ok && val != "" {
plan.ExternalConnectMasterIp = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := master["fqdn"]; ok && val != "" {
plan.ExternalConnectMasterFqdn = types.StringValue(fmt.Sprintf("%v", val))
}
}
if slave, ok := externalConnect["slave"].(map[string]interface{}); ok {
if val, ok := slave["ip"]; ok && val != "" {
plan.ExternalConnectSlaveIp = types.StringValue(fmt.Sprintf("%v", val))
}
if val, ok := slave["fqdn"]; ok && val != "" {
plan.ExternalConnectSlaveFqdn = types.StringValue(fmt.Sprintf("%v", val))
}
}
}
}
}
// fix: plan.ID is Computed (empty in plan), must preserve existing ID from state
plan.ID = state.ID
diags = resp.State.Set(ctx, plan)
resp.Diagnostics.Append(diags...)
}
func (r *PostgresResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
if r.client == nil {
return
}
var plan PostgresResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
if plan.S3Uid.IsNull() || plan.S3Uid.IsUnknown() {
return
}
resolvedS3Uid, err := r.resolveS3Uid(ctx, plan.S3Uid.ValueString())
if err != nil {
resp.Diagnostics.AddError("Invalid s3_uid", err.Error())
return
}
if resolvedS3Uid == "" || resolvedS3Uid == plan.S3Uid.ValueString() {
return
}
plan.S3Uid = types.StringValue(resolvedS3Uid)
resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...)
}
func (r *PostgresResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data PostgresResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
if data.DeletionProtection.ValueBool() {
tflog.Warn(ctx, "Deletion Protection is ENABLED for Postgres. Resource will remain active in Nubes Cloud. Manual cleanup required.")
return
}
tflog.Info(ctx, "Deletion Protection is DISABLED for Postgres. Triggering 'suspend'...")
instanceId := data.ID.ValueString()
// Выполняем операцию suspend
// Для Postgres используем тот же механизм Instance Run что и для VDC
err := r.triggerSuspend(ctx, instanceId)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to suspend Postgres: %s", err))
return
}
tflog.Info(ctx, "Postgres suspended successfully. It will be permanently deleted from the cloud in 14 days.")
}
func (r *PostgresResource) triggerSuspend(ctx context.Context, instanceId string) error {
// Выполняем операцию suspend через новый унифицированный метод
err := r.client.RunAction(ctx, instanceId, "suspend")
if err != nil {
return err
}
// Ждем пока статус изменится на suspended
return r.client.WaitForInstanceStatus(ctx, instanceId, "suspended")
}
func (r *PostgresResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
// Retrieve the full instance data
inst, err := r.client.GetInstanceFull(ctx, req.ID)
if err != nil {
resp.Diagnostics.AddError("Failed to fetch instance for import", err.Error())
return
}
var state PostgresResourceModel
state.ID = types.StringValue(req.ID)
// Top-level fields
if v, ok := inst["displayName"].(string); ok {
state.Name = types.StringValue(v)
}
if v, ok := inst["resourceRealm"].(string); ok {
state.ResourceRealm = types.StringValue(v)
}
// State params
if stateMap, ok := inst["state"].(map[string]interface{}); ok {
if params, ok := stateMap["params"].(map[string]interface{}); ok {
// String or Int handling
toInt64 := func(v interface{}) int64 {
switch val := v.(type) {
case float64:
return int64(val)
case int:
return int64(val)
case string:
if i, err := strconv.ParseInt(val, 10, 64); err == nil {
return i
}
}
return 0
}
if v, ok := params["resourceCPU"]; ok {
state.Cpu = types.Int64Value(toInt64(v))
}
if v, ok := params["resourceMemory"]; ok {
state.Ram = types.Int64Value(toInt64(v))
}
if v, ok := params["resourceDisk"]; ok {
state.DiskSize = types.Int64Value(toInt64(v))
}
if v, ok := params["resourceInstances"]; ok {
state.NodesCount = types.Int64Value(toInt64(v))
}
if v, ok := params["s3Uid"]; ok {
raw := fmt.Sprintf("%v", v)
resolvedS3Uid, err := r.resolveS3Uid(ctx, raw)
if err != nil {
resp.Diagnostics.AddWarning("Failed to resolve s3_uid", err.Error())
state.S3Uid = types.StringValue(raw)
} else if resolvedS3Uid != "" {
state.S3Uid = types.StringValue(resolvedS3Uid)
}
}
if v, ok := params["appVersion"]; ok {
state.Version = types.StringValue(fmt.Sprintf("%v", v))
}
if v, ok := params["ext_BACKUP_SCHEDULE"]; ok {
state.BackupSchedule = types.StringValue(fmt.Sprintf("%v", v))
}
if v, ok := params["ext_BACKUP_NUM_TO_RETAIN"]; ok {
state.BackupRetention = types.Int64Value(toInt64(v))
}
// Booleans
toBool := func(v interface{}) bool {
switch val := v.(type) {
case bool:
return val
case string:
return val == "true"
}
return false
}
if v, ok := params["enablePgPoolerMaster"]; ok {
state.EnablePgPoolerMaster = types.BoolValue(toBool(v))
}
if v, ok := params["enablePgPoolerSlave"]; ok {
state.EnablePgPoolerSlave = types.BoolValue(toBool(v))
}
if v, ok := params["allowNoSSL"]; ok {
state.AllowNoSSL = types.BoolValue(toBool(v))
}
if v, ok := params["autoScale"]; ok {
state.AutoScale = types.BoolValue(toBool(v))
}
if v, ok := params["needExternalAddressMaster"]; ok {
state.EnableExternalMaster = types.BoolValue(toBool(v))
}
if v, ok := params["needExternalAddressSlave"]; ok {
state.EnableExternalSlave = types.BoolValue(toBool(v))
}
if v, ok := params["ipSpaceNameMaster"]; ok {
if s := fmt.Sprintf("%v", v); s != "" {
state.IpSpaceMaster = types.StringValue(s)
}
}
if v, ok := params["ipSpaceNameSlave"]; ok {
if s := fmt.Sprintf("%v", v); s != "" {
state.IpSpaceSlave = types.StringValue(s)
}
}
// Autoscale details
if v, ok := params["autoScalePercentage"]; ok {
state.AutoScalePercentage = types.Int64Value(toInt64(v))
}
if v, ok := params["autoScaleTechWindow"]; ok {
state.AutoScaleTechWindow = types.Int64Value(toInt64(v))
}
if v, ok := params["autoScaleQuotaGb"]; ok {
state.AutoScaleQuotaGb = types.Int64Value(toInt64(v))
}
// JSON Parameters (map -> json string)
// Нормализуем через json.Compact чтобы совпадало с планом (jsonencode тоже compact)
if v, ok := params["jsonParameters"]; ok {
if jsonBytes, err := json.Marshal(v); err == nil {
var buf bytes.Buffer
if err2 := json.Compact(&buf, jsonBytes); err2 == nil {
state.Parameters = types.StringValue(buf.String())
} else {
state.Parameters = types.StringValue(string(jsonBytes))
}
}
}
}
}
state.DeletionProtection = types.BoolValue(true)
diags := resp.State.Set(ctx, &state)
resp.Diagnostics.Append(diags...)
}