diff --git a/docs/data-sources/postgresflex_instance.md b/docs/data-sources/postgresflex_instance.md index 91f4136a2..1b1566522 100644 --- a/docs/data-sources/postgresflex_instance.md +++ b/docs/data-sources/postgresflex_instance.md @@ -33,15 +33,48 @@ data "stackit_postgresflex_instance" "example" { ### Read-Only -- `acl` (List of String) The Access Control List (ACL) for the PostgresFlex instance. -- `backup_schedule` (String) +- `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)) +- `encryption` (Attributes) (see [below for nested schema](#nestedatt--encryption)) - `flavor` (Attributes) (see [below for nested schema](#nestedatt--flavor)) +- `flavor_id` (String) - `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`region`,`instance_id`". - `name` (String) Instance name. +- `network` (Attributes) (see [below for nested schema](#nestedatt--network)) - `replicas` (Number) +- `retention_days` (Number) - `storage` (Attributes) (see [below for nested schema](#nestedatt--storage)) - `version` (String) + +### Nested Schema for `connection_info` + +Read-Only: + +- `write` (Attributes) The DNS name and port in the instance overview. (see [below for nested schema](#nestedatt--connection_info--write)) + + +### Nested Schema for `connection_info.write` + +Read-Only: + +- `host` (String) The host of the instance. +- `port` (Number) The port of the instance. + + + + +### Nested Schema for `encryption` + +Read-Only: + +- `kek_key_id` (String) +- `kek_key_version` (String) +- `kek_keyring_id` (String) +- `service_account` (String) + + ### Nested Schema for `flavor` @@ -50,9 +83,21 @@ Read-Only: - `cpu` (Number) - `description` (String) - `id` (String) +- `node_type` (String) - `ram` (Number) + +### Nested Schema for `network` + +Read-Only: + +- `access_scope` (String) 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. Possible values are: `PUBLIC`, `SNA`. +- `acl` (List of String) List of IPV4 cidr. +- `instance_address` (String) +- `router_address` (String) + + ### Nested Schema for `storage` diff --git a/docs/data-sources/postgresflex_user.md b/docs/data-sources/postgresflex_user.md index 5e91aeba3..e74b1c5e7 100644 --- a/docs/data-sources/postgresflex_user.md +++ b/docs/data-sources/postgresflex_user.md @@ -35,8 +35,8 @@ data "stackit_postgresflex_user" "example" { ### Read-Only -- `host` (String) +- `host` (String, Deprecated) - `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`region`,`instance_id`,`user_id`". -- `port` (Number) +- `port` (Number, Deprecated) - `roles` (Set of String) - `username` (String) diff --git a/docs/resources/postgresflex_instance.md b/docs/resources/postgresflex_instance.md index 081a1cd2f..e8786c8ca 100644 --- a/docs/resources/postgresflex_instance.md +++ b/docs/resources/postgresflex_instance.md @@ -14,20 +14,19 @@ Postgres Flex instance resource schema. Must have a `region` specified in the pr ```terraform resource "stackit_postgresflex_instance" "example" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - name = "example-instance" - acl = ["XXX.XXX.XXX.X/XX", "XX.XXX.XX.X/XX"] - backup_schedule = "00 00 * * *" - flavor = { - cpu = 2 - ram = 4 + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-instance" + network = { + acl = ["XXX.XXX.XXX.X/XX", "XX.XXX.XX.X/XX"] } - replicas = 3 + backup_schedule = "0 0 * * *" + flavor_id = "4.8-replica" storage = { class = "premium-perf2-stackit" size = 5 } - version = 14 + version = "17" + retention_days = 32 } ``` @@ -36,24 +35,55 @@ resource "stackit_postgresflex_instance" "example" { ### Required -- `acl` (List of String) 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 * * *'. -- `flavor` (Attributes) (see [below for nested schema](#nestedatt--flavor)) - `name` (String) Instance name. - `project_id` (String) STACKIT project ID to which the instance is associated. -- `replicas` (Number) How many replicas the instance should have. Valid values are 1 for single mode or 3 for replication. - `storage` (Attributes) (see [below for nested schema](#nestedatt--storage)) - `version` (String) ### Optional +- `acl` (List of String, Deprecated) The Access Control List (ACL) for the PostgresFlex instance. +- `encryption` (Attributes) (see [below for nested schema](#nestedatt--encryption)) +- `flavor` (Attributes) (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 storage classes using the [STACKIT CLI](https://github.com/stackitcloud/stackit-cli): +```bash +stackit postgresflex options --flavors +``` +- `network` (Attributes) The network configuration of the instance. Will be required after February 2027. Set a value to prevent breaking changes. (see [below for nested schema](#nestedatt--network)) - `region` (String) The resource region. If not defined, the provider region is used. +- `replicas` (Number) How many replicas the instance should have. Valid values are 1 for single mode or 3 for replication. Can only be set together with `flavor` +- `retention_days` (Number) How long backups are retained. The value can only be between 32 and 90 days. Will be required after February 2027. Set a value to prevent breaking changes. ### Read-Only +- `connection_info` (Attributes) The connection info for the PostgresFlex instance. (see [below for nested schema](#nestedatt--connection_info)) - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`instance_id`". - `instance_id` (String) ID of the PostgresFlex instance. + +### Nested Schema for `storage` + +Required: + +- `class` (String) The storage class. You can list available storage classes using the [STACKIT CLI](https://github.com/stackitcloud/stackit-cli): +```bash +stackit postgresflex options --storages --flavor-id FLAVOR_ID +``` +- `size` (Number) + + + +### Nested Schema for `encryption` + +Required: + +- `kek_key_id` (String) The ID of the Key within the STACKIT-KMS to use for the encryption. +- `kek_key_version` (String) Version of the key within the STACKIT-KMS to use for the encryption. +- `kek_keyring_id` (String) The ID of the keyring where the key is located within the STACKTI-KMS. +- `service_account` (String) Service-Account linked to the Key within the STACKIT-KMS. + + ### Nested Schema for `flavor` @@ -66,18 +96,37 @@ Read-Only: - `description` (String) - `id` (String) +- `node_type` (String) - -### Nested Schema for `storage` + +### Nested Schema for `network` -Required: +Optional: -- `class` (String) The storage class. You can list available storage classes using the [STACKIT CLI](https://github.com/stackitcloud/stackit-cli): -```bash -stackit postgresflex options --storages --flavor-id FLAVOR_ID -``` -- `size` (Number) +- `access_scope` (String) 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. Possible values are: `PUBLIC`, `SNA`. +- `acl` (List of String) The Access Control List (ACL) for the PostgresFlex instance. + +Read-Only: + +- `instance_address` (String) +- `router_address` (String) + + + +### Nested Schema for `connection_info` + +Read-Only: + +- `write` (Attributes) The DNS name and port in the instance overview. (see [below for nested schema](#nestedatt--connection_info--write)) + + +### Nested Schema for `connection_info.write` + +Read-Only: + +- `host` (String) The host of the instance. +- `port` (Number) The port of the instance. ## Import diff --git a/docs/resources/postgresflex_user.md b/docs/resources/postgresflex_user.md index 3f5cdfc65..6d5a8890b 100644 --- a/docs/resources/postgresflex_user.md +++ b/docs/resources/postgresflex_user.md @@ -53,11 +53,11 @@ resource "stackit_postgresflex_user" "example_rotate" { ### Read-Only -- `host` (String) +- `host` (String, Deprecated) - `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`instance_id`,`user_id`". - `password` (String, Sensitive) -- `port` (Number) -- `uri` (String, Sensitive) +- `port` (Number, Deprecated) +- `uri` (String, Sensitive, Deprecated) - `user_id` (String) User ID. ## Import diff --git a/examples/resources/stackit_postgresflex_instance/resource.tf b/examples/resources/stackit_postgresflex_instance/resource.tf index 29efbd539..6ec923662 100644 --- a/examples/resources/stackit_postgresflex_instance/resource.tf +++ b/examples/resources/stackit_postgresflex_instance/resource.tf @@ -1,16 +1,15 @@ resource "stackit_postgresflex_instance" "example" { - project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - name = "example-instance" - acl = ["XXX.XXX.XXX.X/XX", "XX.XXX.XX.X/XX"] - backup_schedule = "00 00 * * *" - flavor = { - cpu = 2 - ram = 4 + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "example-instance" + network = { + acl = ["XXX.XXX.XXX.X/XX", "XX.XXX.XX.X/XX"] } - replicas = 3 + backup_schedule = "0 0 * * *" + flavor_id = "4.8-replica" storage = { class = "premium-perf2-stackit" size = 5 } - version = 14 -} \ No newline at end of file + version = "17" + retention_days = 32 +} diff --git a/go.mod b/go.mod index 9c361ba29..f530b0b66 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.9.0 github.com/stackitcloud/stackit-sdk-go/services/observability v0.24.0 github.com/stackitcloud/stackit-sdk-go/services/opensearch v1.1.0 - github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.11.0 + github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.12.0 github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v1.1.1 github.com/stackitcloud/stackit-sdk-go/services/redis v1.1.1 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.24.0 diff --git a/go.sum b/go.sum index b67e27595..78bb4a31f 100644 --- a/go.sum +++ b/go.sum @@ -712,8 +712,8 @@ github.com/stackitcloud/stackit-sdk-go/services/observability v0.24.0 h1:KhuWPXr github.com/stackitcloud/stackit-sdk-go/services/observability v0.24.0/go.mod h1:0fEZQHm729mBdvg4sNrAhM6KmHROHJSeS2FwCMRk46k= github.com/stackitcloud/stackit-sdk-go/services/opensearch v1.1.0 h1:hooc/E9Qabn8xno1NUd3uJQfUbW5KoY6mgURlnd776c= github.com/stackitcloud/stackit-sdk-go/services/opensearch v1.1.0/go.mod h1:L+NlfC1hilLOqlLLukCj/UDnxlnNrc/oMikcw3Ansyw= -github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.11.0 h1:cuI4NhuFhaZ3tTkBpUM7nt2odKFJkyCcphT/3gGb9CE= -github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.11.0/go.mod h1:yzlakB+f8ur4yAHR6lyCABO+HcEtZG3G2Faj6m5/uW8= +github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.12.0 h1:elIyrDVSYlEtigFO82SWnpfuJZdpv0b2SwvL3M+z9dA= +github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.12.0/go.mod h1:yzlakB+f8ur4yAHR6lyCABO+HcEtZG3G2Faj6m5/uW8= github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v1.1.1 h1:Vly7OKWAwfrbE31N/tUMmLkl7pKybSnek0IPh8offRk= github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v1.1.1/go.mod h1:TwfVVynB/+AKbccSOLk2qZpPL1tdK43BBAiACP6EtSg= github.com/stackitcloud/stackit-sdk-go/services/redis v1.1.1 h1:PkA2mBtQBsCxToMY2g6O4zwMPH4ah3OUt0vkJMje2qo= diff --git a/stackit/internal/services/observability/instance/resource.go b/stackit/internal/services/observability/instance/resource.go index f7e2a9eda..95836f712 100644 --- a/stackit/internal/services/observability/instance/resource.go +++ b/stackit/internal/services/observability/instance/resource.go @@ -540,7 +540,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.Int32{ - int32planmodifier.UseStateForUnknownIf(int32planmodifier.Int32Changed(path.Root("metrics_retention_days")), "sets `UseStateForUnknown` only if `metrics_retention_days` has not changed"), + int32planmodifier.UseStateForUnknownIf(int32planmodifier.Int32Unchanged(path.Root("metrics_retention_days")), "sets `UseStateForUnknown` only if `metrics_retention_days` has not changed"), }, }, "metrics_retention_days_5m_downsampling": schema.Int32Attribute{ @@ -548,7 +548,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.Int32{ - int32planmodifier.UseStateForUnknownIf(int32planmodifier.Int32Changed(path.Root("metrics_retention_days_5m_downsampling")), "sets `UseStateForUnknown` only if `metrics_retention_days_5m_downsampling` has not changed"), + int32planmodifier.UseStateForUnknownIf(int32planmodifier.Int32Unchanged(path.Root("metrics_retention_days_5m_downsampling")), "sets `UseStateForUnknown` only if `metrics_retention_days_5m_downsampling` has not changed"), }, }, "metrics_retention_days_1h_downsampling": schema.Int32Attribute{ @@ -556,7 +556,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.Int32{ - int32planmodifier.UseStateForUnknownIf(int32planmodifier.Int32Changed(path.Root("metrics_retention_days_1h_downsampling")), "sets `UseStateForUnknown` only if `metrics_retention_days_1h_downsampling` has not changed"), + int32planmodifier.UseStateForUnknownIf(int32planmodifier.Int32Unchanged(path.Root("metrics_retention_days_1h_downsampling")), "sets `UseStateForUnknown` only if `metrics_retention_days_1h_downsampling` has not changed"), }, }, "metrics_url": schema.StringAttribute{ diff --git a/stackit/internal/services/postgresflex/database/datasource.go b/stackit/internal/services/postgresflex/database/datasource.go index 15fc5a831..5d56048b9 100644 --- a/stackit/internal/services/postgresflex/database/datasource.go +++ b/stackit/internal/services/postgresflex/database/datasource.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strconv" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils" @@ -17,7 +18,7 @@ import ( "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" ) // Ensure the implementation satisfies the expected interfaces. @@ -130,21 +131,28 @@ func (r *databaseDataSource) Read(ctx context.Context, req datasource.ReadReques projectId := model.ProjectId.ValueString() instanceId := model.InstanceId.ValueString() - databaseId := model.DatabaseId.ValueString() + databaseIdStr := model.DatabaseId.ValueString() 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, "database_id", databaseIdStr) ctx = tflog.SetField(ctx, "region", region) - databaseResp, err := getDatabase(ctx, r.client, projectId, region, instanceId, databaseId) + // In v2 the ID was a string. This was changed in the v3 API. + databaseId, err := strconv.ParseInt(databaseIdStr, 10, 64) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading database", fmt.Sprintf("Parsing database ID: %v", err)) + return + } + + databaseResp, err := r.client.DefaultAPI.GetDatabase(ctx, projectId, region, instanceId, databaseId).Execute() if err != nil { utils.LogError( ctx, &resp.Diagnostics, err, "Reading database", - fmt.Sprintf("Database with ID %q or instance with ID %q does not exist in project %q.", databaseId, instanceId, projectId), + fmt.Sprintf("Database with ID %d or instance with ID %q does not exist in project %q.", databaseId, instanceId, projectId), map[int]string{ http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId), }, diff --git a/stackit/internal/services/postgresflex/database/resource.go b/stackit/internal/services/postgresflex/database/resource.go index f5c2ebb8c..c54063aef 100644 --- a/stackit/internal/services/postgresflex/database/resource.go +++ b/stackit/internal/services/postgresflex/database/resource.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" + "time" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils" @@ -23,7 +25,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" ) // Ensure the implementation satisfies the expected interfaces. @@ -166,16 +168,10 @@ func (r *databaseResource) Schema(_ context.Context, _ resource.SchemaRequest, r "name": schema.StringAttribute{ Description: descriptions["name"], Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, }, "owner": schema.StringAttribute{ Description: descriptions["owner"], Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, }, "region": schema.StringAttribute{ Optional: true, @@ -214,8 +210,21 @@ func (r *databaseResource) Create(ctx context.Context, req resource.CreateReques core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", fmt.Sprintf("Creating API payload: %v", err)) return } + // Create new database - databaseResp, err := r.client.DefaultAPI.CreateDatabase(ctx, projectId, region, instanceId).CreateDatabasePayload(*payload).Execute() + // Workaround: The database creation will be tried 5 times. In some cases the instance might be + // in maintenance mode and the user API is temporary unavailable. Usually this is only for 1-2 seconds. + config := utils.RetryConfig{ + Attempts: 5, + Backoff: func(attempt int) time.Duration { + // Wait for every attempt 5 seconds longer. 5s, 10s, 15s and so on + return time.Duration(attempt*5) * time.Second + }, + RetryStatusCodes: []int{ + http.StatusLocked, + }, + } + databaseResp, err := utils.RetryRequest(ctx, r.client.DefaultAPI.CreateDatabase(ctx, projectId, region, instanceId).CreateDatabasePayload(*payload).Execute, config) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", fmt.Sprintf("Calling API: %v", err)) return @@ -223,14 +232,13 @@ func (r *databaseResource) Create(ctx context.Context, req resource.CreateReques ctx = core.LogResponse(ctx) - if databaseResp == nil || databaseResp.Id == nil || *databaseResp.Id == "" { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", "API didn't return database Id. A database might have been created") + if databaseResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", "API didn't return response. A database might have been created") return } - databaseId := *databaseResp.Id - ctx = tflog.SetField(ctx, "database_id", databaseId) + ctx = tflog.SetField(ctx, "database_id", databaseResp.Id) - database, err := getDatabase(ctx, r.client, projectId, region, instanceId, databaseId) + database, err := r.client.DefaultAPI.GetDatabase(ctx, projectId, region, instanceId, databaseResp.Id).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating database", fmt.Sprintf("Getting database details after creation: %v", err)) return @@ -264,8 +272,8 @@ func (r *databaseResource) Read(ctx context.Context, req resource.ReadRequest, r projectId := model.ProjectId.ValueString() instanceId := model.InstanceId.ValueString() - databaseId := model.DatabaseId.ValueString() - if databaseId == "" { + databaseIdStr := model.DatabaseId.ValueString() + if databaseIdStr == "" { // Resource not yet created; ID is unknown. resp.State.RemoveResource(ctx) return @@ -273,13 +281,19 @@ func (r *databaseResource) Read(ctx context.Context, req resource.ReadRequest, r 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, "database_id", databaseIdStr) ctx = tflog.SetField(ctx, "region", region) - databaseResp, err := getDatabase(ctx, r.client, projectId, region, instanceId, databaseId) + // In v2 the ID was a string. This was changed in the v3 API. + databaseId, err := strconv.ParseInt(databaseIdStr, 10, 64) if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if (errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound) || errors.Is(err, errDatabaseNotFound) { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading database", fmt.Sprintf("Parsing database ID: %v", err)) + return + } + + databaseResp, err := r.client.DefaultAPI.GetDatabase(ctx, projectId, region, instanceId, databaseId).Execute() + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) return } @@ -306,9 +320,79 @@ func (r *databaseResource) Read(ctx context.Context, req resource.ReadRequest, r } // Update updates the resource and sets the updated Terraform state on success. -func (r *databaseResource) Update(ctx context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform - // Update shouldn't be called - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating database", "Database can't be updated") +func (r *databaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // 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() + databaseIdStr := model.DatabaseId.ValueString() + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "instance_id", instanceId) + ctx = tflog.SetField(ctx, "region", region) + ctx = tflog.SetField(ctx, "database_id", databaseIdStr) + + // In v2 the ID was a string. This was changed in the v3 API. + databaseId, err := strconv.ParseInt(databaseIdStr, 10, 64) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating database", fmt.Sprintf("Parsing database ID: %v", err)) + return + } + + // Generate API request body from model + payload, err := toUpdatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating database", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Update the database + // Workaround: The database update will be tried 5 times. In some cases the instance might be + // in maintenance mode and the database API is temporary unavailable. Usually this is only for 1-2 seconds. + config := utils.RetryConfig{ + Attempts: 5, + Backoff: func(attempt int) time.Duration { + // Wait for every attempt 5 seconds longer. 5s, 10s, 15s and so on + return time.Duration(attempt*5) * time.Second + }, + RetryStatusCodes: []int{ + http.StatusLocked, + }, + } + err = utils.RetryRequestWithoutResponse(ctx, r.client.DefaultAPI.PartialUpdateDatabase(ctx, projectId, region, instanceId, databaseId).PartialUpdateDatabasePayload(*payload).Execute, config) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating database", fmt.Sprintf("Calling API: %v", err)) + return + } + + ctx = core.LogResponse(ctx) + + database, err := r.client.DefaultAPI.GetDatabase(ctx, projectId, region, instanceId, databaseId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating database", fmt.Sprintf("Getting database details after update: %v", err)) + return + } + + // Map response body to schema + err = mapFields(database, &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. @@ -325,18 +409,37 @@ func (r *databaseResource) Delete(ctx context.Context, req resource.DeleteReques projectId := model.ProjectId.ValueString() instanceId := model.InstanceId.ValueString() - databaseId := model.DatabaseId.ValueString() + databaseIdStr := model.DatabaseId.ValueString() 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, "database_id", databaseIdStr) ctx = tflog.SetField(ctx, "region", region) - // Delete existing record set - err := r.client.DefaultAPI.DeleteDatabase(ctx, projectId, region, instanceId, databaseId).Execute() + // In v2 the ID was a string. This was changed in the v3 API. + databaseId, err := strconv.ParseInt(databaseIdStr, 10, 64) if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting database", fmt.Sprintf("Parsing database ID: %v", err)) + return + } + + // Delete existing database + // Workaround: The database deletion will be tried 5 times. In some cases the instance might be + // in maintenance mode and the user API is temporary unavailable. Usually this is only for 1-2 seconds. + config := utils.RetryConfig{ + Attempts: 5, + Backoff: func(attempt int) time.Duration { + // Wait for every attempt 5 seconds longer. 5s, 10s, 15s and so on + return time.Duration(attempt*5) * time.Second + }, + RetryStatusCodes: []int{ + http.StatusLocked, + }, + } + err = utils.RetryRequestWithoutResponse(ctx, r.client.DefaultAPI.DeleteDatabase(ctx, projectId, region, instanceId, databaseId).Execute, config) + if err != nil { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) return } core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting database", fmt.Sprintf("Calling API: %v", err)) @@ -373,13 +476,10 @@ func (r *databaseResource) ImportState(ctx context.Context, req resource.ImportS tflog.Info(ctx, "Postgres Flex database state imported") } -func mapFields(databaseResp *postgresflex.InstanceDatabase, model *Model, region string) error { +func mapFields(databaseResp *postgresflex.GetDatabaseResponse, model *Model, region string) error { if databaseResp == nil { return fmt.Errorf("response is nil") } - if databaseResp.Id == nil || *databaseResp.Id == "" { - return fmt.Errorf("id not present") - } if model == nil { return fmt.Errorf("model input is nil") } @@ -387,32 +487,16 @@ func mapFields(databaseResp *postgresflex.InstanceDatabase, model *Model, region var databaseId string if model.DatabaseId.ValueString() != "" { databaseId = model.DatabaseId.ValueString() - } else if databaseResp.Id != nil { - databaseId = *databaseResp.Id } else { - return fmt.Errorf("database id not present") + databaseId = strconv.FormatInt(databaseResp.Id, 10) } model.Id = utils.BuildInternalTerraformId( model.ProjectId.ValueString(), region, model.InstanceId.ValueString(), databaseId, ) model.DatabaseId = types.StringValue(databaseId) - model.Name = types.StringPointerValue(databaseResp.Name) + model.Name = types.StringValue(databaseResp.Name) model.Region = types.StringValue(region) - - if databaseResp.Options != nil { - owner, ok := (databaseResp.Options)["owner"] - if ok { - ownerStr, ok := owner.(string) - if !ok { - return fmt.Errorf("owner is not a string") - } - // If the field is returned between with quotes, we trim them to prevent an inconsistent result after apply - ownerStr = strings.TrimPrefix(ownerStr, `"`) - ownerStr = strings.TrimSuffix(ownerStr, `"`) - model.Owner = types.StringValue(ownerStr) - } - } - + model.Owner = types.StringValue(databaseResp.Owner) return nil } @@ -422,28 +506,18 @@ func toCreatePayload(model *Model) (*postgresflex.CreateDatabasePayload, error) } return &postgresflex.CreateDatabasePayload{ - Name: model.Name.ValueStringPointer(), - Options: &map[string]string{ - "owner": model.Owner.ValueString(), - }, + Name: model.Name.ValueString(), + Owner: model.Owner.ValueStringPointer(), }, nil } -var errDatabaseNotFound = errors.New("database not found") - -// The API does not have a GetDatabase endpoint, only ListDatabases -func getDatabase(ctx context.Context, client *postgresflex.APIClient, projectId, region, instanceId, databaseId string) (*postgresflex.InstanceDatabase, error) { - resp, err := client.DefaultAPI.ListDatabases(ctx, projectId, region, instanceId).Execute() - if err != nil { - return nil, err - } - if resp == nil || resp.Databases == nil { - return nil, fmt.Errorf("response is nil") - } - for _, database := range resp.Databases { - if database.Id != nil && *database.Id == databaseId { - return &database, nil - } +func toUpdatePayload(model *Model) (*postgresflex.PartialUpdateDatabasePayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") } - return nil, errDatabaseNotFound + + return &postgresflex.PartialUpdateDatabasePayload{ + Name: conversion.StringValueToPointer(model.Name), + Owner: conversion.StringValueToPointer(model.Owner), + }, nil } diff --git a/stackit/internal/services/postgresflex/database/resource_test.go b/stackit/internal/services/postgresflex/database/resource_test.go index ff5f5d92c..8f0374d2c 100644 --- a/stackit/internal/services/postgresflex/database/resource_test.go +++ b/stackit/internal/services/postgresflex/database/resource_test.go @@ -5,103 +5,79 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/types" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" ) func TestMapFields(t *testing.T) { const testRegion = "region" tests := []struct { description string - input *postgresflex.InstanceDatabase + input *postgresflex.GetDatabaseResponse region string expected Model isValid bool }{ { - "default_values", - &postgresflex.InstanceDatabase{ - Id: new("uid"), + description: "default_values", + input: &postgresflex.GetDatabaseResponse{ + Id: 123, }, - testRegion, - Model{ - Id: types.StringValue("pid,region,iid,uid"), - DatabaseId: types.StringValue("uid"), + region: testRegion, + expected: Model{ + Id: types.StringValue("pid,region,iid,123"), + DatabaseId: types.StringValue("123"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), - Name: types.StringNull(), - Owner: types.StringNull(), + Name: types.StringValue(""), + Owner: types.StringValue(""), Region: types.StringValue(testRegion), }, - true, + isValid: true, }, { - "simple_values", - &postgresflex.InstanceDatabase{ - Id: new("uid"), - Name: new("dbname"), - Options: map[string]any{ - "owner": "username", - }, + description: "simple_values", + input: &postgresflex.GetDatabaseResponse{ + Id: 123, + Name: "dbname", + Owner: "username", }, - testRegion, - Model{ - Id: types.StringValue("pid,region,iid,uid"), - DatabaseId: types.StringValue("uid"), + region: testRegion, + expected: Model{ + Id: types.StringValue("pid,region,iid,123"), + DatabaseId: types.StringValue("123"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), Name: types.StringValue("dbname"), Owner: types.StringValue("username"), Region: types.StringValue(testRegion), }, - true, + isValid: true, }, { - "null_fields_and_int_conversions", - &postgresflex.InstanceDatabase{ - Id: new("uid"), - Name: new(""), - Options: map[string]any{ - "owner": "", - }, + description: "null_fields_and_int_conversions", + input: &postgresflex.GetDatabaseResponse{ + Id: 123, + Name: "", + Owner: "", }, - testRegion, - Model{ - Id: types.StringValue("pid,region,iid,uid"), - DatabaseId: types.StringValue("uid"), + region: testRegion, + expected: Model{ + Id: types.StringValue("pid,region,iid,123"), + DatabaseId: types.StringValue("123"), 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, + isValid: true, }, { - "empty_response", - &postgresflex.InstanceDatabase{}, - testRegion, - Model{}, - false, - }, - { - "no_resource_id", - &postgresflex.InstanceDatabase{ - Id: new(""), - Name: new("dbname"), - Options: map[string]any{ - "owner": "username", - }, - }, - testRegion, - Model{}, - false, + description: "nil_response", + input: nil, + region: testRegion, + expected: Model{}, + isValid: false, }, } for _, tt := range tests { @@ -135,38 +111,34 @@ func TestToCreatePayload(t *testing.T) { isValid bool }{ { - "default_values", - &Model{ + description: "default_values", + input: &Model{ Name: types.StringValue("dbname"), Owner: types.StringValue("username"), }, - &postgresflex.CreateDatabasePayload{ - Name: new("dbname"), - Options: &map[string]string{ - "owner": "username", - }, + expected: &postgresflex.CreateDatabasePayload{ + Name: "dbname", + Owner: new("username"), }, - true, + isValid: true, }, { - "null_fields", - &Model{ + description: "null_fields", + input: &Model{ Name: types.StringNull(), Owner: types.StringNull(), }, - &postgresflex.CreateDatabasePayload{ - Name: nil, - Options: &map[string]string{ - "owner": "", - }, + expected: &postgresflex.CreateDatabasePayload{ + Name: "", + Owner: nil, }, - true, + isValid: true, }, { - "nil_model", - nil, - nil, - false, + description: "nil_model", + input: nil, + expected: nil, + isValid: false, }, } for _, tt := range tests { diff --git a/stackit/internal/services/postgresflex/instance/datasource.go b/stackit/internal/services/postgresflex/instance/datasource.go index c10d38553..484efc139 100644 --- a/stackit/internal/services/postgresflex/instance/datasource.go +++ b/stackit/internal/services/postgresflex/instance/datasource.go @@ -5,22 +5,20 @@ import ( "fmt" "net/http" - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" - postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils" - "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api/wait" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" ) // Ensure the implementation satisfies the expected interfaces. @@ -63,13 +61,18 @@ func (r *instanceDataSource) Configure(ctx context.Context, req datasource.Confi // Schema defines the schema for the data source. func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { descriptions := map[string]string{ - "main": "Postgres Flex instance data source schema. Must have a `region` specified in the provider configuration.", - "id": "Terraform's internal data source. ID. It is structured as \"`project_id`,`region`,`instance_id`\".", - "instance_id": "ID of the PostgresFlex instance.", - "project_id": "STACKIT project ID to which the instance is associated.", - "name": "Instance name.", - "acl": "The Access Control List (ACL) for the PostgresFlex instance.", - "region": "The resource region. If not defined, the provider region is used.", + "main": "Postgres Flex instance data source schema. Must have a `region` specified in the provider configuration.", + "id": "Terraform's internal data source. ID. It is structured as \"`project_id`,`region`,`instance_id`\".", + "instance_id": "ID of the PostgresFlex instance.", + "project_id": "STACKIT project ID to which the instance is associated.", + "name": "Instance name.", + "acl": "The Access Control List (ACL) for the PostgresFlex instance.", + "region": "The resource region. If not defined, the provider region is used.", + "backup_schedule": "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": "The connection info for the PostgresFlex instance.", + "connection_info.write": "The DNS name and port in the instance overview.", + "connection_info.write.host": "The host of the instance.", + "connection_info.write.port": "The port of the instance.", } resp.Schema = schema.Schema{ @@ -100,12 +103,38 @@ func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques Computed: true, }, "acl": schema.ListAttribute{ - Description: descriptions["acl"], - ElementType: types.StringType, - Computed: true, + Description: descriptions["acl"], + DeprecationMessage: "acl is deprecated and will be removed after February 2027. Use instead `network.acl`.", + ElementType: types.StringType, + Computed: true, }, "backup_schedule": schema.StringAttribute{ - Computed: true, + Description: descriptions["backup_schedule"], + Computed: true, + }, + "connection_info": schema.SingleNestedAttribute{ + Description: descriptions["connection_info"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "write": schema.SingleNestedAttribute{ + Description: descriptions["connection_info.write"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "host": schema.StringAttribute{ + Description: descriptions["connection_info.write.host"], + Computed: true, + }, + "port": schema.Int32Attribute{ + Description: descriptions["connection_info.write.port"], + Computed: true, + }, + }, + }, + }, + }, + "flavor_id": schema.StringAttribute{ + Description: descriptions["flavor_id"], + Computed: true, }, "flavor": schema.SingleNestedAttribute{ Computed: true, @@ -122,10 +151,60 @@ func (r *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaReques "ram": schema.Int64Attribute{ Computed: true, }, + "node_type": schema.StringAttribute{ + Computed: true, + }, }, }, - "replicas": schema.Int32Attribute{ + "encryption": schema.SingleNestedAttribute{ Computed: true, + Attributes: map[string]schema.Attribute{ + "kek_key_id": schema.StringAttribute{ + Description: descriptions["encryption.kek_key_id"], + Computed: true, + }, + "kek_keyring_id": schema.StringAttribute{ + Description: descriptions["encryption.kek_keyring_id"], + Computed: true, + }, + "kek_key_version": schema.StringAttribute{ + Description: descriptions["encryption.kek_key_version"], + Computed: true, + }, + "service_account": schema.StringAttribute{ + Description: descriptions["encryption.service_account"], + Computed: true, + }, + }, + }, + "replicas": schema.Int32Attribute{ + Description: descriptions["replicas"], + Computed: true, + }, + "retention_days": schema.Int32Attribute{ + Description: descriptions["retention_days"], + Computed: true, + }, + "network": schema.SingleNestedAttribute{ + Description: descriptions["network"], + Computed: true, + Attributes: map[string]schema.Attribute{ + "access_scope": schema.StringAttribute{ + Description: "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)...), + Computed: true, + }, + "acl": schema.ListAttribute{ + Description: "List of IPV4 cidr.", + ElementType: types.StringType, + Computed: true, + }, + "instance_address": schema.StringAttribute{ + Computed: true, + }, + "router_address": schema.StringAttribute{ + Computed: true, + }, + }, }, "storage": schema.SingleNestedAttribute{ Computed: true, @@ -185,30 +264,21 @@ func (r *instanceDataSource) Read(ctx context.Context, req datasource.ReadReques ctx = core.LogResponse(ctx) - if instanceResp != nil && instanceResp.Item != nil && instanceResp.Item.Status != nil && *instanceResp.Item.Status == wait.InstanceStateDeleted { - resp.State.RemoveResource(ctx) - core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", "Instance was deleted successfully") + flavorResp, err := getFlavor(ctx, r.client.DefaultAPI, projectId, region, instanceResp.FlavorId) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Finding flavor %q: %v", instanceResp.FlavorId, err)) return } - var flavor = &flavorModel{} - if !(model.Flavor.IsNull() || model.Flavor.IsUnknown()) { - diags = model.Flavor.As(ctx, flavor, basetypes.ObjectAsOptions{}) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - } - var storage = &storageModel{} - if !(model.Storage.IsNull() || model.Storage.IsUnknown()) { - diags = model.Storage.As(ctx, storage, basetypes.ObjectAsOptions{}) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } + flavor := &flavorModel{ + Id: types.StringValue(flavorResp.Id), + Description: types.StringValue(flavorResp.Description), + CPU: types.Int64Value(flavorResp.Cpu), + RAM: types.Int64Value(flavorResp.Memory), + NodeType: types.StringValue(flavorResp.NodeType), } - err = mapFields(ctx, instanceResp, &model, flavor, storage, region) + err = mapFields(ctx, instanceResp, &model, flavor, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API payload: %v", err)) return diff --git a/stackit/internal/services/postgresflex/instance/resource.go b/stackit/internal/services/postgresflex/instance/resource.go index 8f60eb3b9..be2f3a92a 100644 --- a/stackit/internal/services/postgresflex/instance/resource.go +++ b/stackit/internal/services/postgresflex/instance/resource.go @@ -9,19 +9,26 @@ import ( "strings" "time" - postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils" - + "github.com/hashicorp/terraform-plugin-framework-validators/int32validator" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "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/int32planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" - stringplanmodifierCustom "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/stringplanmodifier" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/listplanmodifier" + stringplanmodifier2 "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils/planmodifiers/stringplanmodifier" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -30,8 +37,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api/wait" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api/wait" ) // Ensure the implementation satisfies the expected interfaces. @@ -42,16 +49,30 @@ var ( _ resource.ResourceWithModifyPlan = &instanceResource{} ) +const ( + NODE_TYPE_SINGLE = "Single" + NODE_TYPE_REPLICA = "Replica" + + NODE_TYPE_SINGLE_VALUE int32 = 1 + NODE_TYPE_REPLICA_VALUE int32 = 3 +) + type Model struct { - Id types.String `tfsdk:"id"` // needed by TF - InstanceId types.String `tfsdk:"instance_id"` - ProjectId types.String `tfsdk:"project_id"` - Name types.String `tfsdk:"name"` + Id types.String `tfsdk:"id"` // needed by TF + InstanceId types.String `tfsdk:"instance_id"` + ProjectId types.String `tfsdk:"project_id"` + Name types.String `tfsdk:"name"` + // Deprecated: ACL is deprecated and will be removed after February 2027. ACL types.List `tfsdk:"acl"` BackupSchedule types.String `tfsdk:"backup_schedule"` + ConnectionInfo types.Object `tfsdk:"connection_info"` Flavor types.Object `tfsdk:"flavor"` + FlavorId types.String `tfsdk:"flavor_id"` Replicas types.Int32 `tfsdk:"replicas"` Storage types.Object `tfsdk:"storage"` + Encryption types.Object `tfsdk:"encryption"` + Network types.Object `tfsdk:"network"` + RetentionDays types.Int32 `tfsdk:"retention_days"` Version types.String `tfsdk:"version"` Region types.String `tfsdk:"region"` } @@ -62,6 +83,7 @@ type flavorModel struct { Description types.String `tfsdk:"description"` CPU types.Int64 `tfsdk:"cpu"` RAM types.Int64 `tfsdk:"ram"` + NodeType types.String `tfsdk:"node_type"` } // Types corresponding to flavorModel @@ -70,6 +92,7 @@ var flavorTypes = map[string]attr.Type{ "description": basetypes.StringType{}, "cpu": basetypes.Int64Type{}, "ram": basetypes.Int64Type{}, + "node_type": basetypes.StringType{}, } // Struct corresponding to Model.Storage @@ -84,6 +107,49 @@ var storageTypes = map[string]attr.Type{ "size": basetypes.Int64Type{}, } +// Struct corresponding to Model.Network +type networkModel struct { + Acl types.List `tfsdk:"acl"` + AccessScope types.String `tfsdk:"access_scope"` + InstanceAddress types.String `tfsdk:"instance_address"` + RouterAddress types.String `tfsdk:"router_address"` +} + +// Types corresponding to networkModel +var networkTypes = map[string]attr.Type{ + "acl": basetypes.ListType{ElemType: types.StringType}, + "access_scope": basetypes.StringType{}, + "instance_address": basetypes.StringType{}, + "router_address": basetypes.StringType{}, +} + +// Struct corresponding to Model.Encryption +type encryptionModel struct { + KekKeyId types.String `tfsdk:"kek_key_id"` + KekKeyRingId types.String `tfsdk:"kek_keyring_id"` + KekKeyVersion types.String `tfsdk:"kek_key_version"` + ServiceAccount types.String `tfsdk:"service_account"` +} + +// Types corresponding to encryptionModel +var encryptionTypes = map[string]attr.Type{ + "kek_key_id": basetypes.StringType{}, + "kek_keyring_id": basetypes.StringType{}, + "kek_key_version": basetypes.StringType{}, + "service_account": basetypes.StringType{}, +} + +// Types corresponding to connectionInfoModel +var connectionInfoTypes = map[string]attr.Type{ + "write": basetypes.ObjectType{AttrTypes: connectionInfoWriteTypes}, +} + +// Types corresponding to connectionInfoModel +var connectionInfoWriteTypes = map[string]attr.Type{ + "host": basetypes.StringType{}, + "port": basetypes.Int32Type{}, +} + // NewInstanceResource is a helper function to simplify the provider implementation. func NewInstanceResource() resource.Resource { return &instanceResource{} @@ -119,12 +185,44 @@ func (r *instanceResource) ModifyPlan(ctx context.Context, req resource.ModifyPl return } + handleV3Migration(ctx, &planModel, &configModel, resp) + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) if resp.Diagnostics.HasError() { return } } +func handleV3Migration(_ context.Context, planModel, configModel *Model, resp *resource.ModifyPlanResponse) { + if configModel == nil { + resp.Diagnostics.AddError("handling v3 migration", "configModel is nil. This is an error in the provider. Please report it https://github.com/stackitcloud/terraform-provider-stackit/issues") + return + } + if planModel == nil { + resp.Diagnostics.AddError("handling v3 migration", "planModel is nil. This is an error in the provider. Please report it https://github.com/stackitcloud/terraform-provider-stackit/issues") + return + } + + // retention_days + if configModel.RetentionDays.IsNull() || configModel.RetentionDays.IsUnknown() { + if planModel.RetentionDays.IsNull() || planModel.RetentionDays.IsUnknown() { + planModel.RetentionDays = types.Int32Value(32) + } + resp.Diagnostics.AddAttributeWarning(path.Root("retention_days"), + "retention_days will be required after February 2027", "retention_days will be a required field after February 2027. Set a value to prevent breaking changes. Fallback to 32 days during deprecation period.") + } + + // backup_schedule + if !(planModel.BackupSchedule.IsNull() || planModel.BackupSchedule.IsUnknown()) { + backupSchedule := planModel.BackupSchedule.ValueString() + backupScheduleSimplified := utils.SimplifyCronString(backupSchedule) + if backupSchedule != backupScheduleSimplified { + resp.Diagnostics.AddAttributeWarning(path.Root("backup_schedule"), + "backup_schedule is invalid", fmt.Sprintf("backup_schedule is not correctly defined and will result in an error after February 2027. Set it to the value %q to prevent errors in future releases.", backupScheduleSimplified)) + } + } +} + // Metadata returns the resource type name. func (r *instanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_postgresflex_instance" @@ -148,17 +246,31 @@ func (r *instanceResource) Configure(ctx context.Context, req resource.Configure // Schema defines the schema for the resource. func (r *instanceResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + willBeRequired := " Will be required after February 2027. Set a value to prevent breaking changes." descriptions := map[string]string{ - "main": "Postgres Flex instance 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`\".", - "instance_id": "ID of the PostgresFlex instance.", - "project_id": "STACKIT project ID to which the instance is associated.", - "name": "Instance name.", - "acl": "The Access Control List (ACL) for the PostgresFlex instance.", - "region": "The resource region. If not defined, the provider region is used.", - "backup_schedule": "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 * * *'.", - "replicas": "How many replicas the instance should have. Valid values are 1 for single mode or 3 for replication.", - "storage_class": "The storage class. You can list available storage classes using the [STACKIT CLI](https://github.com/stackitcloud/stackit-cli):\n```bash\nstackit postgresflex options --storages --flavor-id FLAVOR_ID\n```", + "main": "Postgres Flex instance 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`\".", + "instance_id": "ID of the PostgresFlex instance.", + "project_id": "STACKIT project ID to which the instance is associated.", + "name": "Instance name.", + "acl": "The Access Control List (ACL) for the PostgresFlex instance.", + "region": "The resource region. If not defined, the provider region is used.", + "backup_schedule": "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": "The connection info for the PostgresFlex instance.", + "connection_info.write": "The DNS name and port in the instance overview.", + "connection_info.write.host": "The host of the instance.", + "connection_info.write.port": "The port of the instance.", + "replicas": "How many replicas the instance should have. Valid values are 1 for single mode or 3 for replication. Can only be set together with `flavor`", + "flavor_id": "The flavor ID of the PostgreSQL Flex instance. Can only be set when `flavor` and `replicas` are not set. You can list available storage classes using the [STACKIT CLI](https://github.com/stackitcloud/stackit-cli):\n```bash\nstackit postgresflex options --flavors\n```", + "encryption.kek_key_id": "The ID of the Key within the STACKIT-KMS to use for the encryption.", + "encryption.kek_keyring_id": "The ID of the keyring where the key is located within the STACKTI-KMS.", + "encryption.kek_key_version": "Version of the key within the STACKIT-KMS to use for the encryption.", + "encryption.service_account": "Service-Account linked to the Key within the STACKIT-KMS.", + "storage_class": "The storage class. You can list available storage classes using the [STACKIT CLI](https://github.com/stackitcloud/stackit-cli):\n```bash\nstackit postgresflex options --storages --flavor-id FLAVOR_ID\n```", + "network": "The network configuration of the instance." + willBeRequired, + "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, } resp.Schema = schema.Schema{ @@ -206,30 +318,89 @@ func (r *instanceResource) Schema(_ context.Context, req resource.SchemaRequest, }, }, "acl": schema.ListAttribute{ - Description: descriptions["acl"], - ElementType: types.StringType, - Required: true, + Description: descriptions["acl"], + DeprecationMessage: "acl is deprecated and will be removed after February 2027. Use instead `network.acl`.", + ElementType: types.StringType, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.UseStateForUnknownIf(listplanmodifier.ListUnchanged(path.Root("network").AtName("acl")), "sets `UseStateForUnknown` only if `network.acl` has not changed"), + }, + Validators: []validator.List{ + listvalidator.ExactlyOneOf( + path.Root("acl").Expression(), + path.Root("network").AtName("acl").Expression(), + ), + }, }, "backup_schedule": schema.StringAttribute{ - Description: descriptions["backup_schedule"], - Required: true, + Description: descriptions["backup_schedule"], + Required: true, + PlanModifiers: []planmodifier.String{}, + }, + "connection_info": schema.SingleNestedAttribute{ + Description: descriptions["connection_info"], + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseNonNullStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "write": schema.SingleNestedAttribute{ + Description: descriptions["connection_info.write"], + Computed: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.UseNonNullStateForUnknown(), + }, + Attributes: map[string]schema.Attribute{ + "host": schema.StringAttribute{ + Description: descriptions["connection_info.write.host"], + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseNonNullStateForUnknown(), + }, + }, + "port": schema.Int32Attribute{ + Description: descriptions["connection_info.write.port"], + Computed: true, + PlanModifiers: []planmodifier.Int32{ + int32planmodifier.UseNonNullStateForUnknown(), + }, + }, + }, + }, + }, + }, + "flavor_id": schema.StringAttribute{ + Description: descriptions["flavor_id"], + Computed: true, + Optional: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.Root("flavor_id").Expression(), + path.Root("flavor").Expression(), + ), + stringvalidator.All(), + }, PlanModifiers: []planmodifier.String{ - stringplanmodifierCustom.CronNormalizationModifier{}, + UseStateForUnknownIfFlavorUnchanged(req), }, }, "flavor": schema.SingleNestedAttribute{ - Required: true, + Optional: true, + Computed: true, Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, PlanModifiers: []planmodifier.String{ UseStateForUnknownIfFlavorUnchanged(req), + stringplanmodifier2.UseStateForUnknownIf(stringplanmodifier2.StringUnchanged(path.Root("flavor_id")), "sets `UseStateForUnknown` if `flavor_id` remains unchanged"), }, }, "description": schema.StringAttribute{ Computed: true, PlanModifiers: []planmodifier.String{ UseStateForUnknownIfFlavorUnchanged(req), + stringplanmodifier2.UseStateForUnknownIf(stringplanmodifier2.StringUnchanged(path.Root("flavor_id")), "sets `UseStateForUnknown` if `flavor_id` remains unchanged"), }, }, "cpu": schema.Int64Attribute{ @@ -238,11 +409,122 @@ func (r *instanceResource) Schema(_ context.Context, req resource.SchemaRequest, "ram": schema.Int64Attribute{ Required: true, }, + "node_type": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + UseStateForUnknownIfFlavorUnchanged(req), + stringplanmodifier2.UseStateForUnknownIf(stringplanmodifier2.StringUnchanged(path.Root("flavor_id")), "sets `UseStateForUnknown` if `flavor_id` remains unchanged"), + }, + }, + }, + Validators: []validator.Object{ + objectvalidator.ExactlyOneOf( + path.Root("flavor_id").Expression(), + path.Root("flavor").Expression(), + ), + objectvalidator.AlsoRequires(path.Root("replicas").Expression()), + }, + }, + "encryption": schema.SingleNestedAttribute{ + Optional: true, + PlanModifiers: []planmodifier.Object{ + objectplanmodifier.RequiresReplace(), + }, + Attributes: map[string]schema.Attribute{ + "kek_key_id": schema.StringAttribute{ + Description: descriptions["encryption.kek_key_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "kek_keyring_id": schema.StringAttribute{ + Description: descriptions["encryption.kek_keyring_id"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "kek_key_version": schema.StringAttribute{ + Description: descriptions["encryption.kek_key_version"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "service_account": schema.StringAttribute{ + Description: descriptions["encryption.service_account"], + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, }, }, "replicas": schema.Int32Attribute{ Description: descriptions["replicas"], - Required: true, + Optional: true, + Computed: true, + Validators: []validator.Int32{ + int32validator.OneOf(1, 3), + int32validator.AlsoRequires(path.Root("flavor").Expression()), + int32validator.ConflictsWith(path.Root("flavor_id").Expression()), + }, + PlanModifiers: []planmodifier.Int32{ + int32planmodifier.UseNonNullStateForUnknown(), + }, + }, + "retention_days": schema.Int32Attribute{ + Description: descriptions["retention_days"], + Optional: true, + Computed: true, + Validators: []validator.Int32{ + int32validator.Between(32, 90), + }, + }, + "network": schema.SingleNestedAttribute{ + Description: descriptions["network"], + Computed: true, + Optional: true, + Attributes: map[string]schema.Attribute{ + "access_scope": schema.StringAttribute{ + Description: descriptions["network.access_scope"], + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "acl": schema.ListAttribute{ + Description: descriptions["acl"], + ElementType: types.StringType, + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.List{ + listplanmodifier.UseStateForUnknownIf(listplanmodifier.ListUnchanged(path.Root("acl")), "sets `UseStateForUnknown` only if `acl` has not changed"), + }, + Validators: []validator.List{ + listvalidator.ExactlyOneOf( + path.Root("acl").Expression(), + path.Root("network").AtName("acl").Expression(), + ), + listvalidator.SizeAtLeast(1), + }, + }, + "instance_address": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "router_address": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, }, "storage": schema.SingleNestedAttribute{ Required: true, @@ -321,9 +603,26 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques return } } + var network = &networkModel{} + if !(model.Network.IsNull() || model.Network.IsUnknown()) { + diags = model.Network.As(ctx, network, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + var encryption *encryptionModel + if !(model.Encryption.IsNull() || model.Encryption.IsUnknown()) { + encryption = &encryptionModel{} + diags = model.Encryption.As(ctx, encryption, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } // Generate API request body from model - payload, err := toCreatePayload(&model, acl, flavor, storage) + payload, err := toCreatePayload(&model, acl, flavor, storage, network, encryption) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Creating API payload: %v", err)) return @@ -336,30 +635,29 @@ func (r *instanceResource) Create(ctx context.Context, req resource.CreateReques } ctx = core.LogResponse(ctx) - if createResp.Id == nil { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "Got empty instance id") + if createResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", "Got empty response") return } - instanceId := *createResp.Id // Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ "project_id": projectId, "region": region, - "instance_id": instanceId, + "instance_id": createResp.Id, }) if resp.Diagnostics.HasError() { return } - waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId).WaitWithContext(ctx) + waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectId, region, createResp.Id).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Instance creation waiting: %v", err)) return } // Map response body to schema - err = mapFields(ctx, waitResp, &model, flavor, storage, region) + err = mapFields(ctx, waitResp, &model, flavor, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating instance", fmt.Sprintf("Processing API payload: %v", err)) return @@ -396,27 +694,9 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r ctx = tflog.SetField(ctx, "instance_id", instanceId) ctx = tflog.SetField(ctx, "region", region) - var flavor = &flavorModel{} - if !(model.Flavor.IsNull() || model.Flavor.IsUnknown()) { - diags = model.Flavor.As(ctx, flavor, basetypes.ObjectAsOptions{}) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - } - var storage = &storageModel{} - if !(model.Storage.IsNull() || model.Storage.IsUnknown()) { - diags = model.Storage.As(ctx, storage, basetypes.ObjectAsOptions{}) - resp.Diagnostics.Append(diags...) - if resp.Diagnostics.HasError() { - return - } - } - instanceResp, err := r.client.DefaultAPI.GetInstance(ctx, projectId, region, instanceId).Execute() if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) return } @@ -426,13 +706,30 @@ func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, r ctx = core.LogResponse(ctx) - if instanceResp != nil && instanceResp.Item != nil && instanceResp.Item.Status != nil && *instanceResp.Item.Status == wait.InstanceStateDeleted { - resp.State.RemoveResource(ctx) - return + var flavor = &flavorModel{} + if !(model.Flavor.IsNull() || model.Flavor.IsUnknown()) { + diags = model.Flavor.As(ctx, flavor, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } else { + flavorResp, err := getFlavor(ctx, r.client.DefaultAPI, projectId, region, instanceResp.FlavorId) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Finding flavor: %v", err)) + return + } + flavor = &flavorModel{ + Id: types.StringValue(flavorResp.Id), + Description: types.StringValue(flavorResp.Description), + CPU: types.Int64Value(flavorResp.Cpu), + RAM: types.Int64Value(flavorResp.Memory), + NodeType: types.StringValue(flavorResp.NodeType), + } } // Map response body to schema - err = mapFields(ctx, instanceResp, &model, flavor, storage, region) + err = mapFields(ctx, instanceResp, &model, flavor, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading instance", fmt.Sprintf("Processing API payload: %v", err)) return @@ -494,15 +791,23 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques return } } + var network = &networkModel{} + if !(model.Network.IsNull() || model.Network.IsUnknown()) { + diags = model.Network.As(ctx, network, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } // Generate API request body from model - payload, err := toUpdatePayload(&model, acl, flavor, storage) + payload, err := toUpdatePayload(&model, acl, flavor, storage, network) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Creating API payload: %v", err)) return } // Update existing instance - _, err = r.client.DefaultAPI.PartialUpdateInstance(ctx, projectId, region, instanceId).PartialUpdateInstancePayload(*payload).Execute() + err = r.client.DefaultAPI.PartialUpdateInstance(ctx, projectId, region, instanceId).PartialUpdateInstancePayload(*payload).Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", err.Error()) return @@ -517,7 +822,7 @@ func (r *instanceResource) Update(ctx context.Context, req resource.UpdateReques } // Map response body to schema - err = mapFields(ctx, waitResp, &model, flavor, storage, region) + err = mapFields(ctx, waitResp, &model, flavor, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating instance", fmt.Sprintf("Processing API payload: %v", err)) return @@ -552,8 +857,7 @@ func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteReques // Delete existing instance err := r.client.DefaultAPI.DeleteInstance(ctx, projectId, region, instanceId).Execute() if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) return } @@ -592,39 +896,32 @@ func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportS tflog.Info(ctx, "Postgres Flex instance state imported") } -func mapFields(ctx context.Context, resp *postgresflex.InstanceResponse, model *Model, flavor *flavorModel, storage *storageModel, region string) error { +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") } - if resp.Item == nil { - return fmt.Errorf("no instance provided") - } if model == nil { return fmt.Errorf("model input is nil") } - instance := resp.Item var instanceId string if model.InstanceId.ValueString() != "" { instanceId = model.InstanceId.ValueString() - } else if instance.Id != nil { - instanceId = *instance.Id + } else if resp.Id != "" { + instanceId = resp.Id } else { return fmt.Errorf("instance id not present") } - var aclList basetypes.ListValue + aclList := types.ListNull(types.StringType) var diags diag.Diagnostics - if instance.Acl == nil || instance.Acl.Items == nil { - aclList = types.ListNull(types.StringType) - } else { - respACL := instance.Acl.Items + if resp.Network.Acl != nil { modelACL, err := utils.ListValueToStringSlice(model.ACL) if err != nil { return err } - reconciledACL := utils.ReconcileStringSlices(modelACL, respACL) + reconciledACL := utils.ReconcileStringSlices(modelACL, resp.Network.Acl) aclList, diags = types.ListValueFrom(ctx, types.StringType, reconciledACL) if diags.HasError() { @@ -632,120 +929,217 @@ func mapFields(ctx context.Context, resp *postgresflex.InstanceResponse, model * } } - var flavorValues map[string]attr.Value - if instance.Flavor == nil { - flavorValues = map[string]attr.Value{ - "id": flavor.Id, - "description": flavor.Description, - "cpu": flavor.CPU, - "ram": flavor.RAM, - } - } else { - flavorValues = map[string]attr.Value{ - "id": types.StringValue(*instance.Flavor.Id), - "description": types.StringValue(*instance.Flavor.Description), - "cpu": types.Int64PointerValue(instance.Flavor.Cpu), - "ram": types.Int64PointerValue(instance.Flavor.Memory), - } + networkValues := map[string]attr.Value{ + "acl": aclList, + "access_scope": types.StringPointerValue((*string)(resp.Network.AccessScope)), + "instance_address": types.StringPointerValue(resp.Network.InstanceAddress), + "router_address": types.StringPointerValue(resp.Network.RouterAddress), + } + networkObject, diags := types.ObjectValue(networkTypes, networkValues) + if diags.HasError() { + return fmt.Errorf("mapping network: %w", core.DiagsToError(diags)) + } + + flavorValues := map[string]attr.Value{ + "id": flavor.Id, + "description": flavor.Description, + "cpu": flavor.CPU, + "ram": flavor.RAM, + "node_type": flavor.NodeType, } flavorObject, diags := types.ObjectValue(flavorTypes, flavorValues) if diags.HasError() { - return fmt.Errorf("creating flavor: %w", core.DiagsToError(diags)) + return fmt.Errorf("mapping flavor: %w", core.DiagsToError(diags)) } - var storageValues map[string]attr.Value - if instance.Storage == nil { - storageValues = map[string]attr.Value{ - "class": storage.Class, - "size": storage.Size, - } - } else { - storageValues = map[string]attr.Value{ - "class": types.StringValue(*instance.Storage.Class), - "size": types.Int64PointerValue(instance.Storage.Size), - } + storageValues := map[string]attr.Value{ + "class": types.StringPointerValue(resp.Storage.Class), + "size": types.Int64PointerValue(resp.Storage.Size), } storageObject, diags := types.ObjectValue(storageTypes, storageValues) if diags.HasError() { - return fmt.Errorf("creating storage: %w", core.DiagsToError(diags)) + return fmt.Errorf("mapping storage: %w", core.DiagsToError(diags)) + } + + encryptionObject := types.ObjectNull(encryptionTypes) + if resp.Encryption != nil { + encryptionValues := map[string]attr.Value{ + "kek_key_id": types.StringValue(resp.Encryption.KekKeyId), + "kek_keyring_id": types.StringValue(resp.Encryption.KekKeyRingId), + "kek_key_version": types.StringValue(resp.Encryption.KekKeyVersion), + "service_account": types.StringValue(resp.Encryption.ServiceAccount), + } + encryptionObject, diags = types.ObjectValue(encryptionTypes, encryptionValues) + if diags.HasError() { + return fmt.Errorf("mapping encryption: %w", core.DiagsToError(diags)) + } } // If the API returned "0 0 * * *" but user defined "00 00 * * *" in its config, // we keep the user's "00 00 * * *" in the state to satisfy Terraform. - backupScheduleApiResp := types.StringPointerValue(instance.BackupSchedule) + backupScheduleApiResp := types.StringValue(resp.BackupSchedule) if utils.SimplifyCronString(model.BackupSchedule.ValueString()) != utils.SimplifyCronString(backupScheduleApiResp.ValueString()) { // If the API actually changed it to something else, use the API value - model.BackupSchedule = types.StringPointerValue(instance.BackupSchedule) + model.BackupSchedule = types.StringValue(resp.BackupSchedule) + } + + // connection info + writeObject, diags := types.ObjectValue(connectionInfoWriteTypes, map[string]attr.Value{ + "host": types.StringValue(resp.ConnectionInfo.Write.Host), + "port": types.Int32Value(resp.ConnectionInfo.Write.Port), + }) + if diags.HasError() { + return fmt.Errorf("mapping connection info write: %w", core.DiagsToError(diags)) + } + + connectionObject, diags := types.ObjectValue(connectionInfoTypes, map[string]attr.Value{ + "write": writeObject, + }) + if diags.HasError() { + return fmt.Errorf("mapping connection info: %w", core.DiagsToError(diags)) + } + + if model.Replicas.IsNull() || model.Replicas.IsUnknown() { + var replica *int32 + switch flavor.NodeType.ValueString() { + case NODE_TYPE_SINGLE: + replica = new(NODE_TYPE_SINGLE_VALUE) + case NODE_TYPE_REPLICA: + replica = new(NODE_TYPE_REPLICA_VALUE) + } + model.Replicas = types.Int32PointerValue(replica) } model.Id = utils.BuildInternalTerraformId(model.ProjectId.ValueString(), region, instanceId) model.InstanceId = types.StringValue(instanceId) - model.Name = types.StringPointerValue(instance.Name) + model.Name = types.StringValue(resp.Name) model.ACL = aclList model.Flavor = flavorObject - model.Replicas = types.Int32PointerValue(instance.Replicas) + model.FlavorId = types.StringValue(resp.FlavorId) + model.Encryption = encryptionObject + model.RetentionDays = types.Int32PointerValue(resp.RetentionDays.Get()) model.Storage = storageObject - model.Version = types.StringPointerValue(instance.Version) + model.Version = types.StringValue(resp.Version) model.Region = types.StringValue(region) + model.Network = networkObject + model.ConnectionInfo = connectionObject return nil } -func toCreatePayload(model *Model, acl []string, flavor *flavorModel, storage *storageModel) (*postgresflex.CreateInstancePayload, error) { +func toCreatePayload(model *Model, acl []string, flavor *flavorModel, storage *storageModel, network *networkModel, encryption *encryptionModel) (*postgresflex.CreateInstancePayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } - if acl == nil { - return nil, fmt.Errorf("nil acl") - } - if flavor == nil { - return nil, fmt.Errorf("nil flavor") - } if storage == nil { return nil, fmt.Errorf("nil storage") } + var flavorId string + if !(model.FlavorId.IsNull() || model.FlavorId.IsUnknown()) { + flavorId = model.FlavorId.ValueString() + } else if flavor != nil && !(flavor.Id.IsNull() || flavor.Id.IsUnknown()) { + flavorId = flavor.Id.ValueString() + } else { + return nil, fmt.Errorf("flavor id is missing") + } + + networkPayload := postgresflex.InstanceNetworkCreate{} + if acl != nil { + networkPayload.Acl = acl + } else if network != nil && !(network.Acl.IsNull() || network.Acl.IsUnknown()) { + var err error + networkPayload.Acl, err = conversion.StringListToSlice(network.Acl) + if err != nil { + return nil, err + } + if !(network.AccessScope.IsUnknown() || network.AccessScope.IsNull()) { + networkPayload.AccessScope = (*postgresflex.InstanceNetworkAccessScope)(network.AccessScope.ValueStringPointer()) + } + } else { + return nil, fmt.Errorf("no acl defined") + } + + var encryptionPayload *postgresflex.InstanceEncryption + if encryption != nil { + encryptionPayload = &postgresflex.InstanceEncryption{ + KekKeyId: encryption.KekKeyId.ValueString(), + KekKeyRingId: encryption.KekKeyRingId.ValueString(), + KekKeyVersion: encryption.KekKeyVersion.ValueString(), + ServiceAccount: encryption.ServiceAccount.ValueString(), + AdditionalProperties: nil, + } + } + + var retentionDays postgresflex.NullableInt32 + if !(model.RetentionDays.IsNull() || model.RetentionDays.IsUnknown()) { + retentionDays = *postgresflex.NewNullableInt32(conversion.Int32ValueToPointer(model.RetentionDays)) + } + + var backupSchedule string + if !(model.BackupSchedule.IsNull() || model.BackupSchedule.IsUnknown()) { + backupSchedule = utils.SimplifyCronString(model.BackupSchedule.ValueString()) + } + return &postgresflex.CreateInstancePayload{ - Acl: postgresflex.ACL{ - Items: acl, - }, - BackupSchedule: model.BackupSchedule.ValueString(), - FlavorId: flavor.Id.ValueString(), + BackupSchedule: backupSchedule, + FlavorId: flavorId, Name: model.Name.ValueString(), - Replicas: model.Replicas.ValueInt32(), - Storage: postgresflex.Storage{ + Network: networkPayload, + Encryption: encryptionPayload, + RetentionDays: retentionDays, + Storage: postgresflex.StorageCreate{ Class: conversion.StringValueToPointer(storage.Class), - Size: conversion.Int64ValueToPointer(storage.Size), + Size: storage.Size.ValueInt64(), }, Version: model.Version.ValueString(), }, nil } -func toUpdatePayload(model *Model, acl []string, flavor *flavorModel, storage *storageModel) (*postgresflex.PartialUpdateInstancePayload, error) { +func toUpdatePayload(model *Model, acl []string, flavor *flavorModel, storage *storageModel, network *networkModel) (*postgresflex.PartialUpdateInstancePayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } - if acl == nil { - return nil, fmt.Errorf("nil acl") - } - if flavor == nil { - return nil, fmt.Errorf("nil flavor") - } if storage == nil { return nil, fmt.Errorf("nil storage") } + var flavorId *string + if flavor != nil && !(flavor.Id.IsNull() || flavor.Id.IsUnknown()) { + flavorId = flavor.Id.ValueStringPointer() + } else if !(model.FlavorId.IsNull() || model.FlavorId.IsUnknown()) { + flavorId = model.FlavorId.ValueStringPointer() + } else { + return nil, fmt.Errorf("flavor id is missing") + } + + networkPayload := &postgresflex.InstanceNetworkOpt{} + if acl != nil { + networkPayload.Acl = acl + } else if network != nil && !(network.Acl.IsNull() || network.Acl.IsUnknown()) { + var err error + networkPayload.Acl, err = conversion.StringListToSlice(network.Acl) + if err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("no acl defined") + } + + var backupSchedule *string + if !(model.BackupSchedule.IsNull() || model.BackupSchedule.IsUnknown()) { + backupSchedule = new(utils.SimplifyCronString(model.BackupSchedule.ValueString())) + } + return &postgresflex.PartialUpdateInstancePayload{ - Acl: &postgresflex.ACL{ - Items: acl, - }, - BackupSchedule: conversion.StringValueToPointer(model.BackupSchedule), - FlavorId: conversion.StringValueToPointer(flavor.Id), + BackupSchedule: backupSchedule, + FlavorId: flavorId, Name: conversion.StringValueToPointer(model.Name), - Replicas: conversion.Int32ValueToPointer(model.Replicas), + Network: networkPayload, Storage: &postgresflex.StorageUpdate{ Size: conversion.Int64ValueToPointer(storage.Size), }, - Version: conversion.StringValueToPointer(model.Version), + RetentionDays: conversion.Int32ValueToPointer(model.RetentionDays), + Version: conversion.StringValueToPointer(model.Version), }, nil } @@ -782,16 +1176,29 @@ func loadFlavorId(ctx context.Context, client postgresFlexClient, model *Model, if res.Flavors == nil { return fmt.Errorf("finding flavors for project %s", projectId) } + if model.Replicas.IsNull() || model.Replicas.IsUnknown() { + return fmt.Errorf("no replicas defined") + } + var nodeType string + switch model.Replicas.ValueInt32() { + case NODE_TYPE_SINGLE_VALUE: + nodeType = NODE_TYPE_SINGLE + case NODE_TYPE_REPLICA_VALUE: + nodeType = NODE_TYPE_REPLICA + default: + return fmt.Errorf("unknown replica count. only 1 and 3 are supported") + } for _, f := range res.Flavors { - if f.Id == nil || f.Cpu == nil || f.Memory == nil { + if f.Id == "" || f.Cpu == 0 || f.Memory == 0 { continue } - if *f.Cpu == *cpu && *f.Memory == *ram { - flavor.Id = types.StringValue(*f.Id) - flavor.Description = types.StringValue(*f.Description) + if f.Cpu == *cpu && f.Memory == *ram && f.NodeType == nodeType { + flavor.Id = types.StringValue(f.Id) + flavor.Description = types.StringValue(f.Description) + flavor.NodeType = types.StringValue(f.NodeType) break } - avl = fmt.Sprintf("%s\n- %d CPU, %d GB RAM", avl, *f.Cpu, *f.Memory) + avl = fmt.Sprintf("%s\n- %d CPU, %d GB RAM, %s node type", avl, f.Cpu, f.Memory, f.NodeType) } if flavor.Id.ValueString() == "" { return fmt.Errorf("couldn't find flavor, available specs are:%s", avl) @@ -799,3 +1206,17 @@ func loadFlavorId(ctx context.Context, client postgresFlexClient, model *Model, return nil } + +func getFlavor(ctx context.Context, client postgresFlexClient, projectId, region, flavorId string) (*postgresflex.ListFlavors, error) { + req := client.ListFlavors(ctx, projectId, region) + flavorsResp, err := client.ListFlavorsExecute(req) + if err != nil { + return nil, fmt.Errorf("failed to list flavors: %w", err) + } + for _, flavor := range flavorsResp.Flavors { + if flavor.Id == flavorId { + return &flavor, nil + } + } + return nil, fmt.Errorf("flavor with ID %q not found in project %q", flavorId, projectId) +} diff --git a/stackit/internal/services/postgresflex/instance/resource_test.go b/stackit/internal/services/postgresflex/instance/resource_test.go index 15efb0297..4f149a719 100644 --- a/stackit/internal/services/postgresflex/instance/resource_test.go +++ b/stackit/internal/services/postgresflex/instance/resource_test.go @@ -7,26 +7,27 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" ) type postgresFlexClientMocked struct { - returnError bool - getFlavorsResp *postgresflex.ListFlavorsResponse - listFlavorsReq postgresflex.ApiListFlavorsRequest + returnError bool + listFlavorsResp *postgresflex.ListFlavorsResponse + listFlavorsReq postgresflex.ApiListFlavorsRequest } func (c *postgresFlexClientMocked) ListFlavors(_ context.Context, _, _ string) postgresflex.ApiListFlavorsRequest { return c.listFlavorsReq } -func (c *postgresFlexClientMocked) ListFlavorsExecute(_ postgresflex.ApiListFlavorsRequest) (*postgresflex.ListFlavorsResponse, error) { +func (c *postgresFlexClientMocked) ListFlavorsExecute(_ postgresflex.ApiListFlavorsRequest) (*postgresflex.ListFlavorsResponse, error) { // nolint:gocritic // function signature required by the Go SDK if c.returnError { return nil, fmt.Errorf("get flavors failed") } - return c.getFlavorsResp, nil + return c.listFlavorsResp, nil } func TestMapFields(t *testing.T) { @@ -37,21 +38,35 @@ func TestMapFields(t *testing.T) { Id: types.StringValue("pid,region,iid"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), - Name: types.StringNull(), + Name: types.StringValue(""), ACL: types.ListNull(types.StringType), BackupSchedule: types.StringNull(), + ConnectionInfo: types.ObjectValueMust(connectionInfoTypes, map[string]attr.Value{ + "write": types.ObjectValueMust(connectionInfoWriteTypes, map[string]attr.Value{ + "host": types.StringValue(""), + "port": types.Int32Value(0), + }), + }), + FlavorId: types.StringValue(""), Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{ "id": types.StringNull(), "description": types.StringNull(), "cpu": types.Int64Null(), "ram": types.Int64Null(), + "node_type": types.StringNull(), }), Replicas: types.Int32Null(), Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{ "class": types.StringNull(), "size": types.Int64Null(), }), - Version: types.StringNull(), + Network: types.ObjectValueMust(networkTypes, map[string]attr.Value{ + "acl": types.ListNull(types.StringType), + "access_scope": types.StringNull(), + "instance_address": types.StringNull(), + "router_address": types.StringNull(), + }), + Version: types.StringValue(""), Region: types.StringValue(testRegion), } @@ -65,65 +80,52 @@ func TestMapFields(t *testing.T) { tests := []struct { description string state Model - input *postgresflex.InstanceResponse + input *postgresflex.GetInstanceResponse flavor *flavorModel - storage *storageModel region string expected Model isValid bool }{ { - "default_values", - Model{ + description: "default_values", + state: Model{ InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), }, - &postgresflex.InstanceResponse{ - Item: &postgresflex.Instance{}, - }, - &flavorModel{}, - &storageModel{}, - testRegion, - fixtureModel(), - true, + input: &postgresflex.GetInstanceResponse{}, + flavor: &flavorModel{}, + region: testRegion, + expected: fixtureModel(), + isValid: true, }, { - "simple_values", - Model{ + description: "simple_values", + state: Model{ InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), }, - &postgresflex.InstanceResponse{ - Item: &postgresflex.Instance{ - Acl: &postgresflex.ACL{ - Items: []string{ - "ip1", - "ip2", - "", - }, - }, - BackupSchedule: new("schedule"), - Flavor: &postgresflex.Flavor{ - Cpu: new(int64(12)), - Description: new("description"), - Id: new("flavor_id"), - Memory: new(int64(34)), - }, - Id: new("iid"), - Name: new("name"), - Replicas: new(int32(56)), - Status: new("status"), - Storage: &postgresflex.Storage{ - Class: new("class"), - Size: new(int64(78)), + input: &postgresflex.GetInstanceResponse{ + Network: postgresflex.InstanceNetwork{ + Acl: []string{ + "ip1", + "ip2", + "", }, - Version: new("version"), }, + BackupSchedule: "schedule", + FlavorId: "4.8", + Id: "iid", + Name: "name", + State: postgresflex.STATE_READY, + Storage: postgresflex.Storage{ + Class: new("class"), + Size: new(int64(78)), + }, + Version: "version", }, - &flavorModel{}, - &storageModel{}, - testRegion, - Model{ + flavor: &flavorModel{}, + region: testRegion, + expected: Model{ Id: types.StringValue("pid,region,iid"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), @@ -133,14 +135,32 @@ func TestMapFields(t *testing.T) { types.StringValue("ip2"), types.StringValue(""), }), + ConnectionInfo: types.ObjectValueMust(connectionInfoTypes, map[string]attr.Value{ + "write": types.ObjectValueMust(connectionInfoWriteTypes, map[string]attr.Value{ + "host": types.StringValue(""), + "port": types.Int32Value(0), + }), + }), BackupSchedule: types.StringValue("schedule"), + Network: types.ObjectValueMust(networkTypes, map[string]attr.Value{ + "acl": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("ip1"), + types.StringValue("ip2"), + types.StringValue(""), + }), + "access_scope": types.StringNull(), + "instance_address": types.StringNull(), + "router_address": types.StringNull(), + }), Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{ - "id": types.StringValue("flavor_id"), - "description": types.StringValue("description"), - "cpu": types.Int64Value(12), - "ram": types.Int64Value(34), + "id": types.StringNull(), + "description": types.StringNull(), + "cpu": types.Int64Null(), + "ram": types.Int64Null(), + "node_type": types.StringNull(), }), - Replicas: types.Int32Value(56), + FlavorId: types.StringValue("4.8"), + Replicas: types.Int32Null(), Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{ "class": types.StringValue("class"), "size": types.Int64Value(78), @@ -148,43 +168,41 @@ func TestMapFields(t *testing.T) { Version: types.StringValue("version"), Region: types.StringValue(testRegion), }, - true, + isValid: true, }, { - "simple_values_no_flavor_and_storage", - Model{ + description: "simple_values_no_flavor_and_storage", + state: Model{ InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), }, - &postgresflex.InstanceResponse{ - Item: &postgresflex.Instance{ - Acl: &postgresflex.ACL{ - Items: []string{ - "ip1", - "ip2", - "", - }, + input: &postgresflex.GetInstanceResponse{ + Network: postgresflex.InstanceNetwork{ + Acl: []string{ + "ip1", + "ip2", + "", }, - BackupSchedule: new("schedule"), - Flavor: nil, - Id: new("iid"), - Name: new("name"), - Replicas: new(int32(56)), - Status: new("status"), - Storage: nil, - Version: new("version"), }, + BackupSchedule: "schedule", + FlavorId: "", + Id: "iid", + Name: "name", + State: postgresflex.STATE_READY, + Storage: postgresflex.Storage{ + Class: new("class"), + Size: new(int64(78)), + }, + Version: "version", }, - &flavorModel{ - CPU: types.Int64Value(12), - RAM: types.Int64Value(34), - }, - &storageModel{ - Class: types.StringValue("class"), - Size: types.Int64Value(78), + flavor: &flavorModel{ + Id: types.StringValue("12.34"), + CPU: types.Int64Value(12), + RAM: types.Int64Value(34), + NodeType: types.StringValue(NODE_TYPE_SINGLE), }, - testRegion, - Model{ + region: testRegion, + expected: Model{ Id: types.StringValue("pid,region,iid"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), @@ -194,14 +212,32 @@ func TestMapFields(t *testing.T) { types.StringValue("ip2"), types.StringValue(""), }), + ConnectionInfo: types.ObjectValueMust(connectionInfoTypes, map[string]attr.Value{ + "write": types.ObjectValueMust(connectionInfoWriteTypes, map[string]attr.Value{ + "host": types.StringValue(""), + "port": types.Int32Value(0), + }), + }), + FlavorId: types.StringValue(""), + Network: types.ObjectValueMust(networkTypes, map[string]attr.Value{ + "acl": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("ip1"), + types.StringValue("ip2"), + types.StringValue(""), + }), + "access_scope": types.StringNull(), + "instance_address": types.StringNull(), + "router_address": types.StringNull(), + }), BackupSchedule: types.StringValue("schedule"), Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{ - "id": types.StringNull(), + "id": types.StringValue("12.34"), "description": types.StringNull(), "cpu": types.Int64Value(12), "ram": types.Int64Value(34), + "node_type": types.StringValue(NODE_TYPE_SINGLE), }), - Replicas: types.Int32Value(56), + Replicas: types.Int32Value(1), Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{ "class": types.StringValue("class"), "size": types.Int64Value(78), @@ -209,11 +245,11 @@ func TestMapFields(t *testing.T) { Version: types.StringValue("version"), Region: types.StringValue(testRegion), }, - true, + isValid: true, }, { - "acl_unordered", - Model{ + description: "acl_unordered", + state: Model{ InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), ACL: types.ListValueMust(types.StringType, []attr.Value{ @@ -222,35 +258,30 @@ func TestMapFields(t *testing.T) { types.StringValue("ip1"), }), }, - &postgresflex.InstanceResponse{ - Item: &postgresflex.Instance{ - Acl: &postgresflex.ACL{ - Items: []string{ - "", - "ip1", - "ip2", - }, + input: &postgresflex.GetInstanceResponse{ + Network: postgresflex.InstanceNetwork{ + Acl: []string{ + "", + "ip1", + "ip2", }, - BackupSchedule: new("schedule"), - Flavor: nil, - Id: new("iid"), - Name: new("name"), - Replicas: new(int32(56)), - Status: new("status"), - Storage: nil, - Version: new("version"), }, + BackupSchedule: "schedule", + Id: "iid", + Name: "name", + Storage: postgresflex.Storage{ + Class: new("class"), + Size: new(int64(78)), + }, + Version: "version", }, - &flavorModel{ - CPU: types.Int64Value(12), - RAM: types.Int64Value(34), + flavor: &flavorModel{ + CPU: types.Int64Value(12), + RAM: types.Int64Value(34), + NodeType: types.StringValue(NODE_TYPE_REPLICA), }, - &storageModel{ - Class: types.StringValue("class"), - Size: types.Int64Value(78), - }, - testRegion, - Model{ + region: testRegion, + expected: Model{ Id: types.StringValue("pid,region,iid"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), @@ -260,36 +291,51 @@ func TestMapFields(t *testing.T) { types.StringValue(""), types.StringValue("ip1"), }), + ConnectionInfo: types.ObjectValueMust(connectionInfoTypes, map[string]attr.Value{ + "write": types.ObjectValueMust(connectionInfoWriteTypes, map[string]attr.Value{ + "host": types.StringValue(""), + "port": types.Int32Value(0), + }), + }), BackupSchedule: types.StringValue("schedule"), Flavor: types.ObjectValueMust(flavorTypes, map[string]attr.Value{ "id": types.StringNull(), "description": types.StringNull(), "cpu": types.Int64Value(12), "ram": types.Int64Value(34), + "node_type": types.StringValue(NODE_TYPE_REPLICA), }), - Replicas: types.Int32Value(56), + FlavorId: types.StringValue(""), + Replicas: types.Int32Value(NODE_TYPE_REPLICA_VALUE), Storage: types.ObjectValueMust(storageTypes, map[string]attr.Value{ "class": types.StringValue("class"), "size": types.Int64Value(78), }), + Network: types.ObjectValueMust(networkTypes, map[string]attr.Value{ + "acl": types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("ip2"), + types.StringValue(""), + types.StringValue("ip1"), + }), + "access_scope": types.StringNull(), + "instance_address": types.StringNull(), + "router_address": types.StringNull(), + }), Version: types.StringValue("version"), Region: types.StringValue(testRegion), }, - true, + isValid: true, }, { description: "backup schedule - keep state value when API strips leading zeros", state: fixtureModel(func(m *Model) { m.BackupSchedule = types.StringValue("00 00 * * *") }), - input: &postgresflex.InstanceResponse{ - Item: &postgresflex.Instance{ - BackupSchedule: new("0 0 * * *"), - }, + input: &postgresflex.GetInstanceResponse{ + BackupSchedule: "0 0 * * *", }, - flavor: &flavorModel{}, - storage: &storageModel{}, - region: testRegion, + flavor: &flavorModel{}, + region: testRegion, expected: fixtureModel(func(m *Model) { m.BackupSchedule = types.StringValue("00 00 * * *") }), @@ -300,49 +346,32 @@ func TestMapFields(t *testing.T) { state: fixtureModel(func(m *Model) { m.BackupSchedule = types.StringValue("00 01 * * *") }), - input: &postgresflex.InstanceResponse{ - Item: &postgresflex.Instance{ - BackupSchedule: new("0 2 * * *"), - }, + input: &postgresflex.GetInstanceResponse{ + BackupSchedule: "0 2 * * *", }, - flavor: &flavorModel{}, - storage: &storageModel{}, - region: testRegion, + flavor: &flavorModel{}, + region: testRegion, expected: fixtureModel(func(m *Model) { m.BackupSchedule = types.StringValue("0 2 * * *") }), isValid: true, }, { - "nil_response", - Model{ - InstanceId: types.StringValue("iid"), - ProjectId: types.StringValue("pid"), - }, - nil, - &flavorModel{}, - &storageModel{}, - testRegion, - Model{}, - false, - }, - { - "no_resource_id", - Model{ + description: "nil_response", + state: Model{ InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), }, - &postgresflex.InstanceResponse{}, - &flavorModel{}, - &storageModel{}, - testRegion, - Model{}, - false, + input: nil, + flavor: &flavorModel{}, + region: testRegion, + expected: Model{}, + isValid: false, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - err := mapFields(context.Background(), tt.input, &tt.state, tt.flavor, tt.storage, tt.region) + err := mapFields(context.Background(), tt.input, &tt.state, tt.flavor, tt.region) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -361,50 +390,63 @@ func TestMapFields(t *testing.T) { func TestToCreatePayload(t *testing.T) { tests := []struct { - description string - input *Model - inputAcl []string - inputFlavor *flavorModel - inputStorage *storageModel - expected *postgresflex.CreateInstancePayload - isValid bool + description string + input *Model + inputAcl []string + inputFlavor *flavorModel + inputStorage *storageModel + inputNetwork *networkModel + inputEncryption *encryptionModel + expected *postgresflex.CreateInstancePayload + isValid bool }{ { - "default_values", - &Model{}, - []string{}, - &flavorModel{}, - &storageModel{}, - &postgresflex.CreateInstancePayload{ - Acl: postgresflex.ACL{ - Items: []string{}, + description: "default_values", + input: &Model{ + FlavorId: types.StringValue("1.2"), + }, + inputAcl: []string{}, + inputFlavor: &flavorModel{}, + inputStorage: &storageModel{}, + inputNetwork: &networkModel{}, + inputEncryption: nil, + expected: &postgresflex.CreateInstancePayload{ + FlavorId: "1.2", + Network: postgresflex.InstanceNetworkCreate{ + Acl: []string{}, }, - Storage: postgresflex.Storage{}, + Storage: postgresflex.StorageCreate{}, }, - true, + isValid: true, }, { - "simple_values", - &Model{ + description: "simple_values", + input: &Model{ BackupSchedule: types.StringValue("schedule"), Name: types.StringValue("name"), Replicas: types.Int32Value(12), Version: types.StringValue("version"), }, - []string{ + inputAcl: []string{ "ip_1", "ip_2", }, - &flavorModel{ + inputFlavor: &flavorModel{ Id: types.StringValue("flavor_id"), }, - &storageModel{ + inputStorage: &storageModel{ Class: types.StringValue("class"), Size: types.Int64Value(34), }, - &postgresflex.CreateInstancePayload{ - Acl: postgresflex.ACL{ - Items: []string{ + inputNetwork: &networkModel{ + Acl: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("test"), + }), + }, + inputEncryption: nil, + expected: &postgresflex.CreateInstancePayload{ + Network: postgresflex.InstanceNetworkCreate{ + Acl: []string{ "ip_1", "ip_2", }, @@ -412,91 +454,121 @@ func TestToCreatePayload(t *testing.T) { BackupSchedule: "schedule", FlavorId: "flavor_id", Name: "name", - Replicas: int32(12), - Storage: postgresflex.Storage{ + Storage: postgresflex.StorageCreate{ Class: new("class"), - Size: new(int64(34)), + Size: 34, }, Version: "version", }, - true, + isValid: true, }, { - "null_fields_and_int_conversions", - &Model{ + description: "null_fields_and_int_conversions", + input: &Model{ BackupSchedule: types.StringNull(), Name: types.StringNull(), Replicas: types.Int32Value(2123456789), Version: types.StringNull(), + FlavorId: types.StringValue("flavor_id"), }, - []string{ + inputAcl: []string{ "", }, - &flavorModel{ + inputFlavor: &flavorModel{ Id: types.StringNull(), }, - &storageModel{ + inputStorage: &storageModel{ Class: types.StringNull(), Size: types.Int64Null(), }, - &postgresflex.CreateInstancePayload{ - Acl: postgresflex.ACL{ - Items: []string{ + inputNetwork: &networkModel{ + Acl: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("test"), + }), + }, + inputEncryption: nil, + expected: &postgresflex.CreateInstancePayload{ + Network: postgresflex.InstanceNetworkCreate{ + Acl: []string{ "", }, }, BackupSchedule: "", - FlavorId: "", + FlavorId: "flavor_id", Name: "", - Replicas: int32(2123456789), - Storage: postgresflex.Storage{ + Storage: postgresflex.StorageCreate{ Class: nil, - Size: nil, + Size: 0, }, Version: "", }, - true, + isValid: true, + }, + { + description: "nil_model", + input: nil, + inputAcl: []string{}, + inputFlavor: &flavorModel{}, + inputStorage: &storageModel{}, + inputNetwork: &networkModel{ + Acl: types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("test"), + }), + }, + inputEncryption: nil, + expected: nil, + isValid: false, }, { - "nil_model", - nil, - []string{}, - &flavorModel{}, - &storageModel{}, - nil, - false, + description: "nil_acl", + input: &Model{}, + inputAcl: nil, + inputFlavor: &flavorModel{}, + inputStorage: &storageModel{}, + inputNetwork: &networkModel{ + Acl: types.ListNull(types.StringType), + }, + inputEncryption: nil, + expected: nil, + isValid: false, }, { - "nil_acl", - &Model{}, - nil, - &flavorModel{}, - &storageModel{}, - nil, - false, + description: "nil_flavor", + input: &Model{}, + inputAcl: []string{}, + inputFlavor: nil, + inputStorage: &storageModel{}, + inputNetwork: &networkModel{}, + inputEncryption: nil, + expected: nil, + isValid: false, }, { - "nil_flavor", - &Model{}, - []string{}, - nil, - &storageModel{}, - nil, - false, + description: "nil_storage", + input: &Model{}, + inputAcl: []string{}, + inputFlavor: &flavorModel{}, + inputStorage: nil, + inputNetwork: &networkModel{}, + inputEncryption: nil, + expected: nil, + isValid: false, }, { - "nil_storage", - &Model{}, - []string{}, - &flavorModel{}, - nil, - nil, - false, + description: "nil_network", + input: &Model{}, + inputAcl: []string{}, + inputFlavor: &flavorModel{}, + inputStorage: &storageModel{}, + inputNetwork: nil, + inputEncryption: nil, + expected: nil, + isValid: false, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - output, err := toCreatePayload(tt.input, tt.inputAcl, tt.inputFlavor, tt.inputStorage) + output, err := toCreatePayload(tt.input, tt.inputAcl, tt.inputFlavor, tt.inputStorage, tt.inputNetwork, tt.inputEncryption) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -504,7 +576,7 @@ func TestToCreatePayload(t *testing.T) { t.Fatalf("Should not have failed: %v", err) } if tt.isValid { - diff := cmp.Diff(output, tt.expected) + diff := cmp.Diff(output, tt.expected, cmp.AllowUnexported(postgresflex.NullableInt32{})) if diff != "" { t.Fatalf("Data does not match: %s", diff) } @@ -520,45 +592,49 @@ func TestToUpdatePayload(t *testing.T) { inputAcl []string inputFlavor *flavorModel inputStorage *storageModel + inputNetwork *networkModel expected *postgresflex.PartialUpdateInstancePayload isValid bool }{ { - "default_values", - &Model{}, - []string{}, - &flavorModel{}, - &storageModel{}, - &postgresflex.PartialUpdateInstancePayload{ - Acl: &postgresflex.ACL{ - Items: []string{}, + description: "default_values", + input: &Model{ + FlavorId: types.StringValue("flavor_id"), + }, + inputAcl: []string{}, + inputFlavor: &flavorModel{}, + inputStorage: &storageModel{}, + expected: &postgresflex.PartialUpdateInstancePayload{ + FlavorId: new("flavor_id"), + Network: &postgresflex.InstanceNetworkOpt{ + Acl: []string{}, }, Storage: &postgresflex.StorageUpdate{}, }, - true, + isValid: true, }, { - "simple_values", - &Model{ + description: "simple_values", + input: &Model{ BackupSchedule: types.StringValue("schedule"), Name: types.StringValue("name"), Replicas: types.Int32Value(12), Version: types.StringValue("version"), }, - []string{ + inputAcl: []string{ "ip_1", "ip_2", }, - &flavorModel{ + inputFlavor: &flavorModel{ Id: types.StringValue("flavor_id"), }, - &storageModel{ + inputStorage: &storageModel{ Class: types.StringValue("class"), Size: types.Int64Value(34), }, - &postgresflex.PartialUpdateInstancePayload{ - Acl: &postgresflex.ACL{ - Items: []string{ + expected: &postgresflex.PartialUpdateInstancePayload{ + Network: &postgresflex.InstanceNetworkOpt{ + Acl: []string{ "ip_1", "ip_2", }, @@ -566,87 +642,86 @@ func TestToUpdatePayload(t *testing.T) { BackupSchedule: new("schedule"), FlavorId: new("flavor_id"), Name: new("name"), - Replicas: new(int32(12)), Version: new("version"), Storage: &postgresflex.StorageUpdate{ Size: new(int64(34)), }, }, - true, + isValid: true, }, { - "null_fields_and_int_conversions", - &Model{ + description: "null_fields_and_int_conversions", + input: &Model{ BackupSchedule: types.StringNull(), Name: types.StringNull(), Replicas: types.Int32Value(2123456789), Version: types.StringNull(), + FlavorId: types.StringValue("flavor_id"), }, - []string{ + inputAcl: []string{ "", }, - &flavorModel{ + inputFlavor: &flavorModel{ Id: types.StringNull(), }, - &storageModel{ + inputStorage: &storageModel{ Class: types.StringNull(), Size: types.Int64Null(), }, - &postgresflex.PartialUpdateInstancePayload{ - Acl: &postgresflex.ACL{ - Items: []string{ + expected: &postgresflex.PartialUpdateInstancePayload{ + Network: &postgresflex.InstanceNetworkOpt{ + Acl: []string{ "", }, }, BackupSchedule: nil, - FlavorId: nil, + FlavorId: new("flavor_id"), Name: nil, - Replicas: new(int32(2123456789)), Version: nil, Storage: &postgresflex.StorageUpdate{}, }, - true, + isValid: true, }, { - "nil_model", - nil, - []string{}, - &flavorModel{}, - &storageModel{}, - nil, - false, + description: "nil_model", + input: nil, + inputAcl: []string{}, + inputFlavor: &flavorModel{}, + inputStorage: &storageModel{}, + expected: nil, + isValid: false, }, { - "nil_acl", - &Model{}, - nil, - &flavorModel{}, - &storageModel{}, - nil, - false, + description: "nil_acl", + input: &Model{}, + inputAcl: nil, + inputFlavor: &flavorModel{}, + inputStorage: &storageModel{}, + expected: nil, + isValid: false, }, { - "nil_flavor", - &Model{}, - []string{}, - nil, - &storageModel{}, - nil, - false, + description: "nil_flavor", + input: &Model{}, + inputAcl: []string{}, + inputFlavor: nil, + inputStorage: &storageModel{}, + expected: nil, + isValid: false, }, { - "nil_storage", - &Model{}, - []string{}, - &flavorModel{}, - nil, - nil, - false, + description: "nil_storage", + input: &Model{}, + inputAcl: []string{}, + inputFlavor: &flavorModel{}, + inputStorage: nil, + expected: nil, + isValid: false, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - output, err := toUpdatePayload(tt.input, tt.inputAcl, tt.inputFlavor, tt.inputStorage) + output, err := toUpdatePayload(tt.input, tt.inputAcl, tt.inputFlavor, tt.inputStorage, tt.inputNetwork) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -667,133 +742,152 @@ func TestLoadFlavorId(t *testing.T) { tests := []struct { description string inputFlavor *flavorModel + inputReplicas int32 mockedResp *postgresflex.ListFlavorsResponse expected *flavorModel getFlavorsFails bool isValid bool }{ { - "ok_flavor", - &flavorModel{ + description: "ok_flavor", + inputFlavor: &flavorModel{ CPU: types.Int64Value(2), RAM: types.Int64Value(8), }, - &postgresflex.ListFlavorsResponse{ - Flavors: []postgresflex.Flavor{ + inputReplicas: NODE_TYPE_SINGLE_VALUE, + mockedResp: &postgresflex.ListFlavorsResponse{ + Flavors: []postgresflex.ListFlavors{ { - Id: new("fid-1"), - Cpu: new(int64(2)), - Description: new("description"), - Memory: new(int64(8)), + Id: "fid-1", + Cpu: 2, + Description: "description", + Memory: 8, + NodeType: NODE_TYPE_SINGLE, }, }, }, - &flavorModel{ + expected: &flavorModel{ Id: types.StringValue("fid-1"), Description: types.StringValue("description"), CPU: types.Int64Value(2), RAM: types.Int64Value(8), + NodeType: types.StringValue(NODE_TYPE_SINGLE), }, - false, - true, + getFlavorsFails: false, + isValid: true, }, { - "ok_flavor_2", - &flavorModel{ + description: "ok_flavor_2", + inputFlavor: &flavorModel{ CPU: types.Int64Value(2), RAM: types.Int64Value(8), }, - &postgresflex.ListFlavorsResponse{ - Flavors: []postgresflex.Flavor{ + inputReplicas: NODE_TYPE_REPLICA_VALUE, + mockedResp: &postgresflex.ListFlavorsResponse{ + Flavors: []postgresflex.ListFlavors{ { - Id: new("fid-1"), - Cpu: new(int64(2)), - Description: new("description"), - Memory: new(int64(8)), + Id: "fid-1", + Cpu: 2, + Description: "description", + Memory: 8, + NodeType: NODE_TYPE_REPLICA, }, { - Id: new("fid-2"), - Cpu: new(int64(1)), - Description: new("description"), - Memory: new(int64(4)), + Id: "fid-2", + Cpu: 1, + Description: "description", + Memory: 4, + NodeType: NODE_TYPE_SINGLE, }, }, }, - &flavorModel{ + expected: &flavorModel{ Id: types.StringValue("fid-1"), Description: types.StringValue("description"), CPU: types.Int64Value(2), RAM: types.Int64Value(8), + NodeType: types.StringValue(NODE_TYPE_REPLICA), }, - false, - true, + getFlavorsFails: false, + isValid: true, }, { - "no_matching_flavor", - &flavorModel{ + description: "no_matching_flavor", + inputFlavor: &flavorModel{ CPU: types.Int64Value(2), RAM: types.Int64Value(8), }, - &postgresflex.ListFlavorsResponse{ - Flavors: []postgresflex.Flavor{ + inputReplicas: NODE_TYPE_SINGLE_VALUE, + mockedResp: &postgresflex.ListFlavorsResponse{ + Flavors: []postgresflex.ListFlavors{ + { + Id: "fid-1", + Cpu: 1, + Description: "description", + Memory: 8, + NodeType: NODE_TYPE_REPLICA, + }, { - Id: new("fid-1"), - Cpu: new(int64(1)), - Description: new("description"), - Memory: new(int64(8)), + Id: "fid-2", + Cpu: 1, + Description: "description", + Memory: 4, + NodeType: NODE_TYPE_REPLICA, }, { - Id: new("fid-2"), - Cpu: new(int64(1)), - Description: new("description"), - Memory: new(int64(4)), + Id: "fid-3", + Cpu: 2, + Description: "description", + Memory: 8, + NodeType: NODE_TYPE_REPLICA, }, }, }, - &flavorModel{ + expected: &flavorModel{ CPU: types.Int64Value(2), RAM: types.Int64Value(8), }, - false, - false, + getFlavorsFails: false, + isValid: false, }, { - "nil_response", - &flavorModel{ + description: "nil_response", + inputFlavor: &flavorModel{ CPU: types.Int64Value(2), RAM: types.Int64Value(8), }, - &postgresflex.ListFlavorsResponse{}, - &flavorModel{ + mockedResp: &postgresflex.ListFlavorsResponse{}, + expected: &flavorModel{ CPU: types.Int64Value(2), RAM: types.Int64Value(8), }, - false, - false, + getFlavorsFails: false, + isValid: false, }, { - "error_response", - &flavorModel{ + description: "error_response", + inputFlavor: &flavorModel{ CPU: types.Int64Value(2), RAM: types.Int64Value(8), }, - &postgresflex.ListFlavorsResponse{}, - &flavorModel{ + mockedResp: &postgresflex.ListFlavorsResponse{}, + expected: &flavorModel{ CPU: types.Int64Value(2), RAM: types.Int64Value(8), }, - true, - false, + getFlavorsFails: true, + isValid: false, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { client := &postgresFlexClientMocked{ - returnError: tt.getFlavorsFails, - getFlavorsResp: tt.mockedResp, + returnError: tt.getFlavorsFails, + listFlavorsResp: tt.mockedResp, } model := &Model{ ProjectId: types.StringValue("pid"), + Replicas: types.Int32Value(tt.inputReplicas), } flavorModel := &flavorModel{ CPU: tt.inputFlavor.CPU, @@ -815,3 +909,191 @@ func TestLoadFlavorId(t *testing.T) { }) } } + +func TestHandleV3Migration(t *testing.T) { + tests := []struct { + name string + configModel *Model + planModel *Model + expectedPlan *Model + warnings int + }{ + { + name: "all_values_provided_no_migration", + configModel: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringValue("0 16 * * *"), + RetentionDays: types.Int32Value(40), + }, + planModel: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringValue("0 16 * * *"), + RetentionDays: types.Int32Value(40), + }, + expectedPlan: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringValue("0 16 * * *"), + RetentionDays: types.Int32Value(40), + }, + warnings: 0, + }, + { + name: "migration_triggered_for_all_null_fields", + configModel: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringNull(), + RetentionDays: types.Int32Null(), + }, + planModel: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringNull(), + RetentionDays: types.Int32Null(), + }, + expectedPlan: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringNull(), + RetentionDays: types.Int32Value(32), + }, + warnings: 1, // retention_days fallback + }, + { + name: "migration_triggered_for_unknown_retention_days", + configModel: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringValue("0 16 * * *"), + RetentionDays: types.Int32Unknown(), + }, + planModel: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringValue("0 16 * * *"), + RetentionDays: types.Int32Unknown(), + }, + expectedPlan: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringValue("0 16 * * *"), + RetentionDays: types.Int32Value(32), + }, + warnings: 1, + }, + { + name: "backup_schedule_unsimplified_triggers_warning", + configModel: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringValue("00 16 * * *"), + RetentionDays: types.Int32Value(40), + }, + planModel: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringValue("00 16 * * *"), + RetentionDays: types.Int32Value(40), + }, + expectedPlan: &Model{ + ACL: types.ListNull(types.StringType), + BackupSchedule: types.StringValue("00 16 * * *"), + RetentionDays: types.Int32Value(40), + }, + warnings: 1, // backup_schedule simplification warning + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := &resource.ModifyPlanResponse{} + handleV3Migration(context.Background(), tt.planModel, tt.configModel, resp) + + if len(resp.Diagnostics.Warnings()) != tt.warnings { + t.Errorf("expected %d warnings, got %d", tt.warnings, len(resp.Diagnostics.Warnings())) + } + + diff := cmp.Diff(tt.planModel, tt.expectedPlan) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestGetFlavor(t *testing.T) { + tests := []struct { + description string + flavorId string + mockedResp *postgresflex.ListFlavorsResponse + expected *postgresflex.ListFlavors + getFlavorsFails bool + isValid bool + }{ + { + description: "ok_flavor_found", + flavorId: "fid-1", + mockedResp: &postgresflex.ListFlavorsResponse{ + Flavors: []postgresflex.ListFlavors{ + { + Id: "fid-1", + Cpu: 2, + Description: "description-1", + Memory: 8, + }, + { + Id: "fid-2", + Cpu: 4, + Description: "description-2", + Memory: 16, + }, + }, + }, + expected: &postgresflex.ListFlavors{ + Id: "fid-1", + Cpu: 2, + Description: "description-1", + Memory: 8, + }, + getFlavorsFails: false, + isValid: true, + }, + { + description: "flavor_not_found", + flavorId: "fid-3", + mockedResp: &postgresflex.ListFlavorsResponse{ + Flavors: []postgresflex.ListFlavors{ + { + Id: "fid-1", + Cpu: 2, + Description: "description-1", + Memory: 8, + }, + }, + }, + expected: nil, + getFlavorsFails: false, + isValid: false, + }, + { + description: "error_response", + flavorId: "fid-1", + mockedResp: nil, + expected: nil, + getFlavorsFails: true, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &postgresFlexClientMocked{ + returnError: tt.getFlavorsFails, + listFlavorsResp: tt.mockedResp, + } + got, err := getFlavor(context.Background(), client, "pid", "region", tt.flavorId) + 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(got, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/services/postgresflex/instance/use_state_for_unknown_if_flavor_unchanged_modifier.go b/stackit/internal/services/postgresflex/instance/use_state_for_unknown_if_flavor_unchanged_modifier.go index 38c924ba2..bf51e5283 100644 --- a/stackit/internal/services/postgresflex/instance/use_state_for_unknown_if_flavor_unchanged_modifier.go +++ b/stackit/internal/services/postgresflex/instance/use_state_for_unknown_if_flavor_unchanged_modifier.go @@ -79,7 +79,7 @@ func (m useStateForUnknownIfFlavorUnchangedModifier) PlanModifyString(ctx contex } } - if planFlavor.CPU == stateFlavor.CPU && planFlavor.RAM == stateFlavor.RAM { + if planFlavor.CPU.Equal(stateFlavor.CPU) && planFlavor.RAM.Equal(stateFlavor.RAM) && planModel.Replicas.Equal(stateModel.Replicas) { resp.PlanValue = req.StateValue } } diff --git a/stackit/internal/services/postgresflex/postgresflex_acc_test.go b/stackit/internal/services/postgresflex/postgresflex_acc_test.go index 515983780..21d4a6f70 100644 --- a/stackit/internal/services/postgresflex/postgresflex_acc_test.go +++ b/stackit/internal/services/postgresflex/postgresflex_acc_test.go @@ -1,297 +1,677 @@ -package postgresflex_test +package postgresflex import ( "context" + _ "embed" + "errors" "fmt" - "strings" + "maps" + "net/http" + "strconv" "testing" + "time" + "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/terraform" - sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api/wait" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api/wait" - - "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" ) -// Instance resource data -var instanceResource = map[string]string{ - "project_id": testutil.ProjectId, - "name": fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum)), - "acl": "192.168.0.0/16", - "backup_schedule": "00 16 * * *", // ensure it works properly with leading zeros - "backup_schedule_updated": "00 12 * * *", - "flavor_cpu": "2", - "flavor_ram": "4", - "flavor_description": "Small, Compute optimized", - "replicas": "1", - "storage_class": "premium-perf12-stackit", - "storage_size": "5", - "storage_size_updated": "10", - "version": "14", - "flavor_id": "2.4", +const ( + retentionDaysDefault = "32" // default value during deprecation period +) + +var ( + // Instance + + //go:embed testdata/resource-instance-min.tf + resourceInstanceMinConfig string + + //go:embed testdata/resource-instance-max.tf + resourceInstanceMaxConfig string + + // Database + + //go:embed testdata/resource-database-min.tf + resourceDatabaseMinConfig string + + // User + + //go:embed testdata/resource-user-min.tf + resourceUserMinConfig string +) + +// Instance - MIN +var testConfigInstanceVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "acl": config.StringVariable("192.168.0.0/24"), + "backup_schedule": config.StringVariable("0 16 * * *"), + "flavor_id": config.StringVariable("4.8-replica"), + "storage_class": config.StringVariable("premium-perf2-stackit"), + "storage_size": config.IntegerVariable(5), + "instance_version": config.StringVariable("16"), + // Only used for the checks + "replicas": config.IntegerVariable(3), } -// User resource data -var userResource = map[string]string{ - "username": fmt.Sprintf("tfaccuser%s", acctest.RandStringFromCharSet(4, acctest.CharSetAlpha)), - "role": "createdb", - "project_id": instanceResource["project_id"], +var testConfigInstanceVarsMinUpdated = func() config.Variables { + updatedConfig := config.Variables{} + maps.Copy(updatedConfig, testConfigInstanceVarsMin) + updatedConfig["name"] = config.StringVariable(fmt.Sprintf( + "%s-updated", testutil.ConvertConfigVariable(updatedConfig["name"]), + )) + updatedConfig["acl"] = config.StringVariable("192.160.2.0/24") + updatedConfig["backup_schedule"] = config.StringVariable("0 10 * * *") + updatedConfig["flavor_id"] = config.StringVariable("4.8") + updatedConfig["storage_size"] = config.IntegerVariable(10) + updatedConfig["instance_version"] = config.StringVariable("17") + return updatedConfig +}() + +// Instance - MAX +var testConfigInstanceVarsMax = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "acl": config.StringVariable("192.168.0.0/24"), + "access_scope": config.StringVariable(string(postgresflex.INSTANCENETWORKACCESSSCOPE_PUBLIC)), + "backup_schedule": config.StringVariable("0 16 * * *"), + "flavor_id": config.StringVariable("4.8-replica"), + "flavor_cpu": config.IntegerVariable(4), + "flavor_ram": config.IntegerVariable(8), + "replicas": config.IntegerVariable(3), + "storage_class": config.StringVariable("premium-perf2-stackit"), + "storage_size": config.IntegerVariable(5), + "instance_version": config.StringVariable("16"), + "retention_days": config.IntegerVariable(40), + "region": config.StringVariable(testutil.Region), } -// Database resource data -var databaseResource = map[string]string{ - "name": fmt.Sprintf("tfaccdb%s", acctest.RandStringFromCharSet(4, acctest.CharSetAlphaNum)), +var testConfigInstanceVarsMaxUpdated = func() config.Variables { + updatedConfig := config.Variables{} + maps.Copy(updatedConfig, testConfigInstanceVarsMax) + updatedConfig["name"] = config.StringVariable(fmt.Sprintf( + "%s-updated", testutil.ConvertConfigVariable(updatedConfig["name"]), + )) + updatedConfig["acl"] = config.StringVariable("192.160.2.0/24") + updatedConfig["backup_schedule"] = config.StringVariable("0 10 * * *") + updatedConfig["flavor_id"] = config.StringVariable("4.8") + updatedConfig["flavor_cpu"] = config.IntegerVariable(8) + updatedConfig["flavor_ram"] = config.IntegerVariable(16) + updatedConfig["replicas"] = config.IntegerVariable(1) + updatedConfig["storage_size"] = config.IntegerVariable(11) + updatedConfig["instance_version"] = config.StringVariable("17") + updatedConfig["retention_days"] = config.IntegerVariable(32) + return updatedConfig +}() + +// Database - MIN +var testConfigDatabaseVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "instance_name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "username": config.StringVariable(fmt.Sprintf("tf_db_acc_%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "acl": config.StringVariable("192.168.0.0/24"), + "backup_schedule": config.StringVariable("0 16 * * *"), + "flavor_id": config.StringVariable("4.8-replica"), + "storage_class": config.StringVariable("premium-perf2-stackit"), + "storage_size": config.IntegerVariable(5), + "instance_version": config.StringVariable("16"), + "roles": config.StringVariable("login"), + + "database_name": config.StringVariable("acc_test"), } -func configResources(backupSchedule, storageSize string, region *string) string { - var regionConfig string - if region != nil { - regionConfig = fmt.Sprintf(`region = %q`, *region) - } - return fmt.Sprintf(` - %s - - resource "stackit_postgresflex_instance" "instance" { - project_id = "%s" - name = "%s" - acl = ["%s"] - backup_schedule = "%s" - flavor = { - cpu = %s - ram = %s - } - replicas = %s - storage = { - class = "%s" - size = %s - } - version = "%s" - %s - } - - resource "stackit_postgresflex_user" "user" { - project_id = stackit_postgresflex_instance.instance.project_id - instance_id = stackit_postgresflex_instance.instance.instance_id - username = "%s" - roles = ["%s"] - } - - resource "stackit_postgresflex_database" "database" { - project_id = stackit_postgresflex_instance.instance.project_id - instance_id = stackit_postgresflex_instance.instance.instance_id - name = "%s" - owner = stackit_postgresflex_user.user.username - } - `, - testutil.NewConfigBuilder().BuildProviderConfig(), - instanceResource["project_id"], - instanceResource["name"], - instanceResource["acl"], - backupSchedule, - instanceResource["flavor_cpu"], - instanceResource["flavor_ram"], - instanceResource["replicas"], - instanceResource["storage_class"], - storageSize, - instanceResource["version"], - regionConfig, - userResource["username"], - userResource["role"], - databaseResource["name"], - ) +var testConfigDatabaseVarsMinUpdated = func() config.Variables { + updatedConfig := config.Variables{} + maps.Copy(updatedConfig, testConfigDatabaseVarsMin) + updatedConfig["username"] = config.StringVariable(fmt.Sprintf( + "%s_updated", testutil.ConvertConfigVariable(updatedConfig["username"]), + )) + updatedConfig["roles"] = config.StringVariable("createdb") + updatedConfig["database_name"] = config.StringVariable("acc_test_updated") + return updatedConfig +}() + +// User - MIN +var testConfigUserVarsMin = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "acl": config.StringVariable("192.168.0.0/24"), + "backup_schedule": config.StringVariable("0 16 * * *"), + "flavor_id": config.StringVariable("4.8-replica"), + "storage_class": config.StringVariable("premium-perf2-stackit"), + "storage_size": config.IntegerVariable(5), + "instance_version": config.StringVariable("16"), + + "username": config.StringVariable("acc_test"), + "roles": config.StringVariable("login"), } -func TestAccPostgresFlexFlexResource(t *testing.T) { - resource.Test(t, resource.TestCase{ +var testConfigUserVarsMinUpdated = func() config.Variables { + updatedConfig := config.Variables{} + maps.Copy(updatedConfig, testConfigUserVarsMin) + updatedConfig["username"] = config.StringVariable("acc_test_updated") + updatedConfig["roles"] = config.StringVariable("createdb") + return updatedConfig +}() + +func TestAccPostgresFlexInstanceMin(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - CheckDestroy: testAccCheckPostgresFlexDestroy, + CheckDestroy: testCheckDestroy, Steps: []resource.TestStep{ // Creation { - Config: configResources(instanceResource["backup_schedule"], instanceResource["storage_size"], &testutil.Region), + ConfigVariables: testConfigInstanceVarsMin, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceInstanceMinConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionCreate), + }, + }, Check: resource.ComposeAggregateTestCheckFunc( // Instance - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "project_id", instanceResource["project_id"]), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["project_id"])), resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "instance_id"), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "name", instanceResource["name"]), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["name"])), resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.#", "1"), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.0", instanceResource["acl"]), - resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "flavor.id"), - resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "flavor.description"), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "backup_schedule", instanceResource["backup_schedule"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor.cpu", instanceResource["flavor_cpu"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor.ram", instanceResource["flavor_ram"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "replicas", instanceResource["replicas"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.class", instanceResource["storage_class"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.size", instanceResource["storage_size"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "version", instanceResource["version"]), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["acl"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "network.acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["acl"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "network.access_scope"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["backup_schedule"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["flavor_id"])), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.id"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.description"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.cpu"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.ram"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "connection_info.write.port"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "replicas"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "retention_days", retentionDaysDefault), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["storage_class"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["storage_size"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["instance_version"])), resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "region", testutil.Region), - - // User - resource.TestCheckResourceAttrPair( - "stackit_postgresflex_user.user", "project_id", - "stackit_postgresflex_instance.instance", "project_id", - ), - resource.TestCheckResourceAttrPair( - "stackit_postgresflex_user.user", "instance_id", - "stackit_postgresflex_instance.instance", "instance_id", - ), - resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "user_id"), - resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "password"), - - // Database - resource.TestCheckResourceAttrPair( - "stackit_postgresflex_database.database", "project_id", - "stackit_postgresflex_instance.instance", "project_id", - ), - resource.TestCheckResourceAttrPair( - "stackit_postgresflex_database.database", "instance_id", - "stackit_postgresflex_instance.instance", "instance_id", - ), - resource.TestCheckResourceAttr("stackit_postgresflex_database.database", "name", databaseResource["name"]), - resource.TestCheckResourceAttrPair( - "stackit_postgresflex_database.database", "owner", - "stackit_postgresflex_user.user", "username", - ), ), }, // data source { + ConfigVariables: testConfigInstanceVarsMin, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionNoop), + }, + }, Config: fmt.Sprintf(` %s + %s data "stackit_postgresflex_instance" "instance" { project_id = stackit_postgresflex_instance.instance.project_id instance_id = stackit_postgresflex_instance.instance.instance_id } - - data "stackit_postgresflex_user" "user" { - project_id = stackit_postgresflex_instance.instance.project_id - instance_id = stackit_postgresflex_instance.instance.instance_id - user_id = stackit_postgresflex_user.user.user_id - } - - data "stackit_postgresflex_database" "database" { - project_id = stackit_postgresflex_instance.instance.project_id - instance_id = stackit_postgresflex_instance.instance.instance_id - database_id = stackit_postgresflex_database.database.database_id - } `, - configResources(instanceResource["backup_schedule"], instanceResource["storage_size"], nil), + testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceInstanceMinConfig, ), Check: resource.ComposeAggregateTestCheckFunc( - // Instance data - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "project_id", instanceResource["project_id"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "name", instanceResource["name"]), - resource.TestCheckResourceAttrPair( - "data.stackit_postgresflex_instance.instance", "project_id", - "stackit_postgresflex_instance.instance", "project_id", - ), - resource.TestCheckResourceAttrPair( - "data.stackit_postgresflex_instance.instance", "instance_id", - "stackit_postgresflex_instance.instance", "instance_id", - ), - resource.TestCheckResourceAttrPair( - "data.stackit_postgresflex_user.user", "instance_id", - "stackit_postgresflex_user.user", "instance_id", - ), - + // Instance + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["name"])), resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "acl.#", "1"), - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "acl.0", instanceResource["acl"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "backup_schedule", instanceResource["backup_schedule"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.id", instanceResource["flavor_id"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.description", instanceResource["flavor_description"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.cpu", instanceResource["flavor_cpu"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor.ram", instanceResource["flavor_ram"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "replicas", instanceResource["replicas"]), - - // User data - resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "project_id", userResource["project_id"]), - resource.TestCheckResourceAttrSet("data.stackit_postgresflex_user.user", "user_id"), - resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "username", userResource["username"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "roles.#", "1"), - resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "roles.0", userResource["role"]), - resource.TestCheckResourceAttrSet("data.stackit_postgresflex_user.user", "host"), - resource.TestCheckResourceAttrSet("data.stackit_postgresflex_user.user", "port"), - - // Database data - resource.TestCheckResourceAttr("data.stackit_postgresflex_database.database", "project_id", instanceResource["project_id"]), - resource.TestCheckResourceAttr("data.stackit_postgresflex_database.database", "name", databaseResource["name"]), - resource.TestCheckResourceAttrPair( - "data.stackit_postgresflex_database.database", "instance_id", - "stackit_postgresflex_instance.instance", "instance_id", - ), - resource.TestCheckResourceAttrPair( - "data.stackit_postgresflex_database.database", "owner", - "data.stackit_postgresflex_user.user", "username", - ), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["acl"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "network.acl.#", "1"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["acl"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "network.access_scope"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["backup_schedule"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["flavor_id"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "flavor.id"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "flavor.description"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "flavor.cpu"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "flavor.ram"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "connection_info.write.port"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "replicas", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["replicas"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "retention_days", retentionDaysDefault), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["storage_class"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["storage_size"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMin["instance_version"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "region", testutil.Region), ), }, // Import { - ResourceName: "stackit_postgresflex_instance.instance", + ConfigVariables: testConfigInstanceVarsMin, + ResourceName: "stackit_postgresflex_instance.instance", ImportStateIdFunc: func(s *terraform.State) (string, error) { r, ok := s.RootModule().Resources["stackit_postgresflex_instance.instance"] if !ok { return "", fmt.Errorf("couldn't find resource stackit_postgresflex_instance.instance") } + + projectId, ok := r.Primary.Attributes["project_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute project_id") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } instanceId, ok := r.Primary.Attributes["instance_id"] if !ok { return "", fmt.Errorf("couldn't find attribute instance_id") } - - return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId), nil + return fmt.Sprintf("%s,%s,%s", projectId, region, instanceId), nil }, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"password", "backup_schedule"}, - ImportStateCheck: func(s []*terraform.InstanceState) error { - if len(s) != 1 { - return fmt.Errorf("expected 1 state, got %d", len(s)) + ImportStateVerifyIgnore: []string{"flavor", "replicas"}, + }, + // Update + { + ConfigVariables: testConfigInstanceVarsMinUpdated, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceInstanceMinConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + // Instance + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMinUpdated["project_id"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsMinUpdated["name"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMinUpdated["acl"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "network.acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMinUpdated["acl"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "network.access_scope"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsMinUpdated["backup_schedule"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMinUpdated["flavor_id"])), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.id"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.description"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.cpu"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.ram"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "connection_info.write.port"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "replicas"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "retention_days", retentionDaysDefault), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMinUpdated["storage_class"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMinUpdated["storage_size"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMinUpdated["instance_version"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "region", testutil.Region), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccPostgresFlexInstanceMax(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testCheckDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigInstanceVarsMax, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceInstanceMaxConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.with_flavor_id", plancheck.ResourceActionCreate), + plancheck.ExpectResourceAction("stackit_postgresflex_instance.with_flavor", plancheck.ResourceActionCreate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + // Instance with flavor id + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "instance_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["name"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["acl"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "network.acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["acl"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "network.access_scope"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["backup_schedule"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "flavor_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["flavor_id"])), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "flavor.id"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "flavor.description"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "flavor.cpu"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "flavor.ram"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "connection_info.write.port"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "replicas"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "retention_days", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["retention_days"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_class"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_size"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["instance_version"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "region", testutil.Region), + + // Instance with flavor + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "instance_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["name"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["acl"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "network.acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["acl"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "network.access_scope"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["backup_schedule"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "flavor_id"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "flavor.id"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "flavor.description"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "flavor.cpu", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["flavor_cpu"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "flavor.ram", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["flavor_ram"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "connection_info.write.port"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "replicas", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["replicas"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "retention_days", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["retention_days"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_class"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_size"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["instance_version"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "region", testutil.Region), + ), + }, + // data source + { + ConfigVariables: testConfigInstanceVarsMax, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.with_flavor_id", plancheck.ResourceActionNoop), + plancheck.ExpectResourceAction("stackit_postgresflex_instance.with_flavor", plancheck.ResourceActionNoop), + }, + }, + Config: fmt.Sprintf(` + %s + + %s + data "stackit_postgresflex_instance" "with_flavor_id" { + project_id = stackit_postgresflex_instance.with_flavor_id.project_id + instance_id = stackit_postgresflex_instance.with_flavor_id.instance_id } - if utils.SimplifyCronString(s[0].Attributes["backup_schedule"]) != utils.SimplifyCronString(instanceResource["backup_schedule"]) { - return fmt.Errorf("expected backup_schedule %s, got %s", instanceResource["backup_schedule"], s[0].Attributes["backup_schedule"]) + data "stackit_postgresflex_instance" "with_flavor" { + project_id = stackit_postgresflex_instance.with_flavor.project_id + instance_id = stackit_postgresflex_instance.with_flavor.instance_id } + `, + testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceInstanceMaxConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance with flavor id + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "instance_id"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["name"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "acl.#", "1"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["acl"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "network.acl.#", "1"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["acl"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "network.access_scope"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["backup_schedule"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "flavor_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["flavor_id"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "flavor.id"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "flavor.description"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "flavor.cpu"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "flavor.ram"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "connection_info.write.port"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor_id", "replicas"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "retention_days", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["retention_days"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_class"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_size"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["instance_version"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor_id", "region", testutil.Region), - return nil - }, + // Instance with flavor + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "instance_id"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["name"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "acl.#", "1"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["acl"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "network.acl.#", "1"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["acl"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "network.access_scope"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["backup_schedule"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "flavor_id"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "flavor.id"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "flavor.description"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "flavor.cpu"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "flavor.ram"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "connection_info.write.port"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.with_flavor", "replicas"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "retention_days", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["retention_days"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_class"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["storage_size"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMax["instance_version"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.with_flavor", "region", testutil.Region), + ), }, + // Import with flavor id { - ResourceName: "stackit_postgresflex_user.user", + ConfigVariables: testConfigInstanceVarsMax, + ResourceName: "stackit_postgresflex_instance.with_flavor_id", ImportStateIdFunc: func(s *terraform.State) (string, error) { - r, ok := s.RootModule().Resources["stackit_postgresflex_user.user"] + r, ok := s.RootModule().Resources["stackit_postgresflex_instance.with_flavor_id"] if !ok { - return "", fmt.Errorf("couldn't find resource stackit_postgresflex_user.user") + return "", fmt.Errorf("couldn't find resource stackit_postgresflex_instance.with_flavor_id") + } + + projectId, ok := r.Primary.Attributes["project_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute project_id") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") } instanceId, ok := r.Primary.Attributes["instance_id"] if !ok { return "", fmt.Errorf("couldn't find attribute instance_id") } - userId, ok := r.Primary.Attributes["user_id"] + return fmt.Sprintf("%s,%s,%s", projectId, region, instanceId), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"flavor", "replicas"}, + }, + // Import with flavor + { + ConfigVariables: testConfigInstanceVarsMax, + ResourceName: "stackit_postgresflex_instance.with_flavor", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_postgresflex_instance.with_flavor"] if !ok { - return "", fmt.Errorf("couldn't find attribute user_id") + return "", fmt.Errorf("couldn't find resource stackit_postgresflex_instance.with_flavor") } - return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId, userId), nil + projectId, ok := r.Primary.Attributes["project_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute project_id") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instance_id") + } + return fmt.Sprintf("%s,%s,%s", projectId, region, instanceId), nil }, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"password", "uri"}, + ImportStateVerifyIgnore: []string{"flavor", "replicas"}, + }, + // Update + { + ConfigVariables: testConfigInstanceVarsMaxUpdated, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceInstanceMaxConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.with_flavor_id", plancheck.ResourceActionUpdate), + plancheck.ExpectResourceAction("stackit_postgresflex_instance.with_flavor", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + // Instance with flavor id + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["project_id"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "instance_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["name"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["acl"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "network.acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["acl"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "network.access_scope"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["backup_schedule"])), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "flavor.id"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "flavor.description"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "flavor.cpu"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "flavor.ram"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor_id", "connection_info.write.port"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor_id", "replicas"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "retention_days", retentionDaysDefault), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["storage_class"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["storage_size"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["instance_version"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor_id", "region", testutil.Region), + + // Instance with flavor + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["project_id"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "instance_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["name"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["acl"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "network.acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["acl"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "network.access_scope"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["backup_schedule"])), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor", "flavor.id"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor", "flavor.description"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor", "flavor.cpu"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor", "flavor.ram"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.with_flavor", "connection_info.write.port"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.with_flavor", "replicas"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "retention_days", retentionDaysDefault), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["storage_class"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["storage_size"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsMaxUpdated["instance_version"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.with_flavor", "region", testutil.Region), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccPostgresFlexDatabaseMin(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testCheckDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigDatabaseVarsMin, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceDatabaseMinConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionCreate), + plancheck.ExpectResourceAction("stackit_postgresflex_user.user", plancheck.ResourceActionCreate), + plancheck.ExpectResourceAction("stackit_postgresflex_database.database", plancheck.ResourceActionCreate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + // Database + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "project_id", + "stackit_postgresflex_database.database", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "instance_id", + "stackit_postgresflex_database.database", "instance_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_user.user", "username", + "stackit_postgresflex_database.database", "owner", + ), + resource.TestCheckResourceAttr("stackit_postgresflex_database.database", "name", testutil.ConvertConfigVariable(testConfigDatabaseVarsMin["database_name"])), + resource.TestCheckResourceAttr("stackit_postgresflex_database.database", "owner", testutil.ConvertConfigVariable(testConfigDatabaseVarsMin["username"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_database.database", "database_id"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_database.database", "id"), + ), + }, + // data source + { + ConfigVariables: testConfigDatabaseVarsMin, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionNoop), + plancheck.ExpectResourceAction("stackit_postgresflex_user.user", plancheck.ResourceActionNoop), + plancheck.ExpectResourceAction("stackit_postgresflex_database.database", plancheck.ResourceActionNoop), + }, + }, + Config: fmt.Sprintf(` + %s + + %s + data "stackit_postgresflex_database" "database" { + project_id = stackit_postgresflex_database.database.project_id + instance_id = stackit_postgresflex_database.database.instance_id + database_id = stackit_postgresflex_database.database.database_id + } + `, + testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceDatabaseMinConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Database + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "project_id", + "data.stackit_postgresflex_database.database", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "instance_id", + "data.stackit_postgresflex_database.database", "instance_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_user.user", "username", + "data.stackit_postgresflex_database.database", "owner", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_database.database", "database_id", + "data.stackit_postgresflex_database.database", "database_id", + ), + resource.TestCheckResourceAttr("data.stackit_postgresflex_database.database", "owner", testutil.ConvertConfigVariable(testConfigDatabaseVarsMin["username"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_database.database", "name", testutil.ConvertConfigVariable(testConfigDatabaseVarsMin["database_name"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_database.database", "id"), + ), }, + // Import { - ResourceName: "stackit_postgresflex_database.database", + ConfigVariables: testConfigDatabaseVarsMin, + ResourceName: "stackit_postgresflex_database.database", ImportStateIdFunc: func(s *terraform.State) (string, error) { r, ok := s.RootModule().Resources["stackit_postgresflex_database.database"] if !ok { return "", fmt.Errorf("couldn't find resource stackit_postgresflex_database.database") } + + projectId, ok := r.Primary.Attributes["project_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute project_id") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } instanceId, ok := r.Primary.Attributes["instance_id"] if !ok { return "", fmt.Errorf("couldn't find attribute instance_id") @@ -300,31 +680,40 @@ func TestAccPostgresFlexFlexResource(t *testing.T) { if !ok { return "", fmt.Errorf("couldn't find attribute database_id") } - - return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, testutil.Region, instanceId, databaseId), nil + return fmt.Sprintf("%s,%s,%s,%s", projectId, region, instanceId, databaseId), nil }, ImportState: true, ImportStateVerify: true, }, // Update { - Config: configResources(instanceResource["backup_schedule_updated"], instanceResource["storage_size_updated"], nil), + ConfigVariables: testConfigDatabaseVarsMinUpdated, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceDatabaseMinConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionNoop), + plancheck.ExpectResourceAction("stackit_postgresflex_user.user", plancheck.ResourceActionUpdate), + plancheck.ExpectResourceAction("stackit_postgresflex_database.database", plancheck.ResourceActionUpdate), + }, + }, Check: resource.ComposeAggregateTestCheckFunc( - // Instance data - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "project_id", instanceResource["project_id"]), - resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "instance_id"), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "name", instanceResource["name"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.#", "1"), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.0", instanceResource["acl"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "backup_schedule", instanceResource["backup_schedule_updated"]), - resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "flavor.id"), - resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "flavor.description"), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor.cpu", instanceResource["flavor_cpu"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor.ram", instanceResource["flavor_ram"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "replicas", instanceResource["replicas"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.class", instanceResource["storage_class"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.size", instanceResource["storage_size_updated"]), - resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "version", instanceResource["version"]), + // Database + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "project_id", + "stackit_postgresflex_database.database", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "instance_id", + "stackit_postgresflex_database.database", "instance_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_user.user", "username", + "stackit_postgresflex_database.database", "owner", + ), + resource.TestCheckResourceAttrSet("stackit_postgresflex_database.database", "database_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_database.database", "name", testutil.ConvertConfigVariable(testConfigDatabaseVarsMinUpdated["database_name"])), + resource.TestCheckResourceAttr("stackit_postgresflex_database.database", "owner", testutil.ConvertConfigVariable(testConfigDatabaseVarsMinUpdated["username"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_database.database", "id"), ), }, // Deletion is done by the framework implicitly @@ -332,43 +721,398 @@ func TestAccPostgresFlexFlexResource(t *testing.T) { }) } -func testAccCheckPostgresFlexDestroy(s *terraform.State) error { +func TestAccPostgresFlexUserMin(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testCheckDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigUserVarsMin, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceUserMinConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionCreate), + plancheck.ExpectResourceAction("stackit_postgresflex_user.user", plancheck.ResourceActionCreate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + // User + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "project_id", + "stackit_postgresflex_user.user", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "instance_id", + "stackit_postgresflex_user.user", "instance_id", + ), + resource.TestCheckResourceAttr("stackit_postgresflex_user.user", "username", testutil.ConvertConfigVariable(testConfigUserVarsMin["username"])), + resource.TestCheckResourceAttr("stackit_postgresflex_user.user", "roles.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_user.user", "roles.0", testutil.ConvertConfigVariable(testConfigUserVarsMin["roles"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "id"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "password"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "port"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "uri"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "user_id"), + ), + }, + // data source + { + ConfigVariables: testConfigUserVarsMin, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionNoop), + plancheck.ExpectResourceAction("stackit_postgresflex_user.user", plancheck.ResourceActionNoop), + }, + }, + Config: fmt.Sprintf(` + %s + + %s + data "stackit_postgresflex_user" "user" { + project_id = stackit_postgresflex_user.user.project_id + instance_id = stackit_postgresflex_user.user.instance_id + user_id = stackit_postgresflex_user.user.user_id + } + `, + testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceUserMinConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // User + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "project_id", + "data.stackit_postgresflex_user.user", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "instance_id", + "data.stackit_postgresflex_user.user", "instance_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_user.user", "user_id", + "data.stackit_postgresflex_user.user", "user_id", + ), + resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "username", testutil.ConvertConfigVariable(testConfigUserVarsMin["username"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "roles.#", "1"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_user.user", "roles.0", testutil.ConvertConfigVariable(testConfigUserVarsMin["roles"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_user.user", "host"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_user.user", "id"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_user.user", "port"), + ), + }, + // Import + { + ConfigVariables: testConfigUserVarsMin, + ResourceName: "stackit_postgresflex_user.user", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_postgresflex_user.user"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_postgresflex_user.user") + } + + projectId, ok := r.Primary.Attributes["project_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute project_id") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instance_id") + } + userId, ok := r.Primary.Attributes["user_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute user_id") + } + return fmt.Sprintf("%s,%s,%s,%s", projectId, region, instanceId, userId), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"password", "uri"}, + }, + // Update + { + ConfigVariables: testConfigUserVarsMinUpdated, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceUserMinConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionNoop), + plancheck.ExpectResourceAction("stackit_postgresflex_user.user", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + // User + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "project_id", + "stackit_postgresflex_user.user", "project_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_postgresflex_instance.instance", "instance_id", + "stackit_postgresflex_user.user", "instance_id", + ), + resource.TestCheckResourceAttr("stackit_postgresflex_user.user", "username", testutil.ConvertConfigVariable(testConfigUserVarsMinUpdated["username"])), + resource.TestCheckResourceAttr("stackit_postgresflex_user.user", "roles.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_user.user", "roles.0", testutil.ConvertConfigVariable(testConfigUserVarsMinUpdated["roles"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "id"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "password"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "port"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "uri"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_user.user", "user_id"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testCheckDestroy(s *terraform.State) error { + checkDestroyFuncs := []resource.TestCheckFunc{ + testDatabaseDestroy, + testUserDestroy, + testInstanceDestroy, + } + + var errs []error + for _, checkDestroyFunc := range checkDestroyFuncs { + err := checkDestroyFunc(s) + if err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +func testUserDestroy(s *terraform.State) error { + ctx := context.Background() + client, err := postgresflex.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.PostgresFlexCustomEndpoint, false)...) + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + type postgresUser struct { + projectId string + region string + instanceId string + userId int64 + } + + var errs []error + usersToDestroy := []postgresUser{} + for _, r := range s.RootModule().Resources { + if r.Type != "stackit_postgresflex_user" { + continue + } + projectId, ok := r.Primary.Attributes["project_id"] + if !ok { + errs = append(errs, fmt.Errorf("no project_id found in %s", r.Primary)) + continue + } + region, ok := r.Primary.Attributes["region"] + if !ok { + errs = append(errs, fmt.Errorf("no region found in %s", r.Primary)) + continue + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + errs = append(errs, fmt.Errorf("no instance_id found in %s", r.Primary)) + continue + } + userIdStr, ok := r.Primary.Attributes["user_id"] + if !ok { + errs = append(errs, fmt.Errorf("no user_id found in %s", r.Primary)) + continue + } + userId, err := strconv.ParseInt(userIdStr, 10, 64) + if err != nil { + errs = append(errs, fmt.Errorf("parsing user id: %w", err)) + continue + } + + usersToDestroy = append(usersToDestroy, postgresUser{ + projectId: projectId, + region: region, + instanceId: instanceId, + userId: userId, + }) + } + + for _, user := range usersToDestroy { + _, err = client.DefaultAPI.GetUser(ctx, user.projectId, user.region, user.instanceId, user.userId).Execute() + if err == nil { + retryConfig := utils.RetryConfig{ + Attempts: 15, + Backoff: func(attempt int) time.Duration { + // Wait for every attempt 5 seconds longer. 5s, 10s, 15s and so on + return time.Duration(attempt*5) * time.Second + }, + RetryStatusCodes: []int{http.StatusLocked}, + } + err = utils.RetryRequestWithoutResponse( + ctx, + client.DefaultAPI.DeleteUser(ctx, user.projectId, user.region, user.instanceId, user.userId).Execute, + retryConfig, + ) + if err != nil { + errs = append(errs, fmt.Errorf("deleting user with ID %d in project %q, region %q, instance %q: %w", user.userId, user.projectId, user.region, user.instanceId, err)) + continue + } + _, err = wait.DeleteUserWaitHandler(ctx, client.DefaultAPI, user.projectId, user.region, user.instanceId, user.userId).WaitWithContext(ctx) + if err != nil { + errs = append(errs, fmt.Errorf("deleting user with ID %d in project %q, region %q, instance %q during CheckDestroy: waiting for deletion %w", user.userId, user.projectId, user.region, user.instanceId, err)) + } + } + } + return errors.Join(errs...) +} + +func testDatabaseDestroy(s *terraform.State) error { ctx := context.Background() client, err := postgresflex.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.PostgresFlexCustomEndpoint, false)...) if err != nil { return fmt.Errorf("creating client: %w", err) } - instancesToDestroy := []string{} - for _, rs := range s.RootModule().Resources { - if rs.Type != "stackit_postgresflex_instance" { + type postgresDatabase struct { + projectId string + region string + instanceId string + databaseId int64 + } + + var errs []error + databasesToDestroy := []postgresDatabase{} + for _, r := range s.RootModule().Resources { + if r.Type != "stackit_postgresflex_database" { + continue + } + projectId, ok := r.Primary.Attributes["project_id"] + if !ok { + errs = append(errs, fmt.Errorf("no project_id found in %s", r.Primary)) + continue + } + region, ok := r.Primary.Attributes["region"] + if !ok { + errs = append(errs, fmt.Errorf("no region found in %s", r.Primary)) + continue + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + errs = append(errs, fmt.Errorf("no instance_id found in %s", r.Primary)) + continue + } + databaseIdStr, ok := r.Primary.Attributes["database_id"] + if !ok { + errs = append(errs, fmt.Errorf("no database_id found in %s", r.Primary)) continue } - // instance terraform ID: = "[project_id],[region],[instance_id]" - instanceId := strings.Split(rs.Primary.ID, core.Separator)[2] - instancesToDestroy = append(instancesToDestroy, instanceId) + databaseId, err := strconv.ParseInt(databaseIdStr, 10, 64) + if err != nil { + errs = append(errs, fmt.Errorf("parsing database id: %w", err)) + continue + } + databasesToDestroy = append(databasesToDestroy, postgresDatabase{ + projectId: projectId, + region: region, + instanceId: instanceId, + databaseId: databaseId, + }) } - instancesResp, err := client.DefaultAPI.ListInstances(ctx, testutil.ProjectId, testutil.Region).Execute() + for _, db := range databasesToDestroy { + _, err = client.DefaultAPI.GetDatabase(ctx, db.projectId, db.region, db.instanceId, db.databaseId).Execute() + if err == nil { + retryConfig := utils.RetryConfig{ + Attempts: 15, + Backoff: func(attempt int) time.Duration { + // Wait for every attempt 5 seconds longer. 5s, 10s, 15s and so on + return time.Duration(attempt*5) * time.Second + }, + RetryStatusCodes: []int{http.StatusLocked}, + } + err = utils.RetryRequestWithoutResponse( + ctx, + client.DefaultAPI.DeleteDatabase(ctx, db.projectId, db.region, db.instanceId, db.databaseId).Execute, + retryConfig, + ) + if err != nil { + errs = append(errs, fmt.Errorf("deleting database with ID %d in project %q, region %q, instance id %q: %w", db.databaseId, db.projectId, db.region, db.instanceId, err)) + continue + } + } + } + return errors.Join(errs...) +} + +func testInstanceDestroy(s *terraform.State) error { + ctx := context.Background() + client, err := postgresflex.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.PostgresFlexCustomEndpoint, false)...) if err != nil { - return fmt.Errorf("getting instancesResp: %w", err) + return fmt.Errorf("creating client: %w", err) } - items := instancesResp.Items - for i := range items { - if items[i].Id == nil { + type postgresInstance struct { + projectId string + region string + instanceId string + } + + var errs []error + instancesToDestroy := []postgresInstance{} + for _, r := range s.RootModule().Resources { + if r.Type != "stackit_postgresflex_instance" { + continue + } + projectId, ok := r.Primary.Attributes["project_id"] + if !ok { + errs = append(errs, fmt.Errorf("no project_id found in %s", r.Primary)) + continue + } + region, ok := r.Primary.Attributes["region"] + if !ok { + errs = append(errs, fmt.Errorf("no region found in %s", r.Primary)) continue } - if sdkUtils.Contains(instancesToDestroy, *items[i].Id) { - err := client.DefaultAPI.ForceDeleteInstance(ctx, testutil.ProjectId, testutil.Region, *items[i].Id).Execute() + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + errs = append(errs, fmt.Errorf("no instance_id found in %s", r.Primary)) + continue + } + instancesToDestroy = append(instancesToDestroy, postgresInstance{ + projectId: projectId, + region: region, + instanceId: instanceId, + }) + } + + for _, inst := range instancesToDestroy { + _, err = client.DefaultAPI.GetInstance(ctx, inst.projectId, inst.region, inst.instanceId).Execute() + if err == nil { + retryConfig := utils.RetryConfig{ + Attempts: 15, + Backoff: func(attempt int) time.Duration { + // Wait for every attempt 5 seconds longer. 5s, 10s, 15s and so on + return time.Duration(attempt*5) * time.Second + }, + RetryStatusCodes: []int{http.StatusLocked}, + } + err = utils.RetryRequestWithoutResponse( + ctx, + client.DefaultAPI.DeleteInstance(ctx, inst.projectId, inst.region, inst.instanceId).Execute, + retryConfig, + ) if err != nil { - return fmt.Errorf("deleting instance %s during CheckDestroy: %w", *items[i].Id, err) + errs = append(errs, fmt.Errorf("deleting instance with ID %q in project %q, region %q: %w", inst.instanceId, inst.projectId, inst.region, err)) + continue } - _, err = wait.ForceDeleteInstanceWaitHandler(ctx, client.DefaultAPI, testutil.ProjectId, testutil.Region, *items[i].Id).WaitWithContext(ctx) + _, err = wait.DeleteInstanceWaitHandler(ctx, client.DefaultAPI, inst.projectId, inst.region, inst.instanceId).WaitWithContext(ctx) if err != nil { - return fmt.Errorf("deleting instance %s during CheckDestroy: waiting for deletion %w", *items[i].Id, err) + errs = append(errs, fmt.Errorf("deleting instance with ID %q in project %q, region %q during CheckDestroy: waiting for deletion %w", inst.instanceId, inst.projectId, inst.region, err)) } } } - return nil + return errors.Join(errs...) } diff --git a/stackit/internal/services/postgresflex/postgresflex_private_preview_acc_test.go b/stackit/internal/services/postgresflex/postgresflex_private_preview_acc_test.go new file mode 100644 index 000000000..fbe4d141c --- /dev/null +++ b/stackit/internal/services/postgresflex/postgresflex_private_preview_acc_test.go @@ -0,0 +1,235 @@ +package postgresflex + +import ( + _ "embed" + "fmt" + "maps" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + kms "github.com/stackitcloud/stackit-sdk-go/services/kms/v1api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" + + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +var ( + // Instance + + //go:embed testdata/resource-instance-private-preview-max.tf + resourceInstancePrivatePreviewMaxConfig string +) + +// Instance - MAX +var testConfigInstanceVarsPrivatePreviewMax = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "kek_key_version": config.StringVariable("1"), + "service_account_email": config.StringVariable(testutil.TestProjectServiceAccountEmail), + "keyring_display_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), + "display_name": config.StringVariable("tf-acc-" + acctest.RandStringFromCharSet(8, acctest.CharSetAlpha)), + "algorithm": config.StringVariable(string(kms.ALGORITHM_AES_256_GCM)), + "protection": config.StringVariable("software"), + "purpose": config.StringVariable(string(kms.PURPOSE_SYMMETRIC_ENCRYPT_DECRYPT)), + + "name": config.StringVariable(fmt.Sprintf("tf-acc-%s", acctest.RandStringFromCharSet(7, acctest.CharSetAlphaNum))), + "acl": config.StringVariable("192.168.0.0/24"), + "access_scope": config.StringVariable(string(postgresflex.INSTANCENETWORKACCESSSCOPE_PUBLIC)), + "backup_schedule": config.StringVariable("0 16 * * *"), + "flavor_id": config.StringVariable("4.8-replica"), + "flavor_cpu": config.IntegerVariable(4), + "flavor_ram": config.IntegerVariable(8), + "replicas": config.IntegerVariable(3), + "storage_class": config.StringVariable("premium-perf2-stackit"), + "storage_size": config.IntegerVariable(5), + "instance_version": config.StringVariable("16"), + "retention_days": config.IntegerVariable(40), + "region": config.StringVariable(testutil.Region), +} + +var testConfigInstanceVarsPrivatePreviewMaxUpdated = func() config.Variables { + updatedConfig := config.Variables{} + maps.Copy(updatedConfig, testConfigInstanceVarsPrivatePreviewMax) + updatedConfig["name"] = config.StringVariable(fmt.Sprintf( + "%s-updated", testutil.ConvertConfigVariable(updatedConfig["name"]), + )) + updatedConfig["acl"] = config.StringVariable("192.160.2.0/24") + updatedConfig["backup_schedule"] = config.StringVariable("1 0 * * *") + updatedConfig["flavor_id"] = config.StringVariable("4.8") + updatedConfig["flavor_cpu"] = config.IntegerVariable(8) + updatedConfig["flavor_ram"] = config.IntegerVariable(16) + updatedConfig["replicas"] = config.IntegerVariable(1) + updatedConfig["storage_size"] = config.IntegerVariable(11) + updatedConfig["instance_version"] = config.StringVariable("17") + updatedConfig["retention_days"] = config.IntegerVariable(32) + return updatedConfig +}() + +func TestAccPostgresFlexInstancePrivatePreviewMax(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testCheckDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigInstanceVarsPrivatePreviewMax, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceInstancePrivatePreviewMaxConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionCreate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + // Instance with flavor id + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["project_id"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["name"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["acl"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "network.acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["acl"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "network.access_scope"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["backup_schedule"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "flavor_id", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["flavor_id"])), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.id"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.description"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.cpu"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.ram"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "connection_info.write.port"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "replicas"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "retention_days", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["retention_days"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["storage_class"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["storage_size"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["instance_version"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "region", testutil.Region), + + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "encryption.kek_key_id"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "encryption.kek_keyring_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "encryption.kek_key_version", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["kek_key_version"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "encryption.service_account", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["service_account_email"])), + ), + }, + // data source + { + ConfigVariables: testConfigInstanceVarsPrivatePreviewMax, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionNoop), + }, + }, + Config: fmt.Sprintf(` + %s + + %s + data "stackit_postgresflex_instance" "instance" { + project_id = stackit_postgresflex_instance.instance.project_id + instance_id = stackit_postgresflex_instance.instance.instance_id + } + `, + testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceInstancePrivatePreviewMaxConfig, + ), + Check: resource.ComposeAggregateTestCheckFunc( + // Instance with flavor id + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["project_id"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["name"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "acl.#", "1"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["acl"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "network.acl.#", "1"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["acl"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "network.access_scope"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["backup_schedule"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "flavor_id", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["flavor_id"])), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "flavor.id"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "flavor.description"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "flavor.cpu"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "flavor.ram"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "connection_info.write.port"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "replicas"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "retention_days", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["retention_days"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["storage_class"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["storage_size"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["instance_version"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "region", testutil.Region), + + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "encryption.kek_key_id"), + resource.TestCheckResourceAttrSet("data.stackit_postgresflex_instance.instance", "encryption.kek_keyring_id"), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "encryption.kek_key_version", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["kek_key_version"])), + resource.TestCheckResourceAttr("data.stackit_postgresflex_instance.instance", "encryption.service_account", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMax["service_account_email"])), + ), + }, + // Import with flavor id + { + ConfigVariables: testConfigInstanceVarsPrivatePreviewMax, + ResourceName: "stackit_postgresflex_instance.instance", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_postgresflex_instance.instance"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_postgresflex_instance.instance") + } + + projectId, ok := r.Primary.Attributes["project_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute project_id") + } + region, ok := r.Primary.Attributes["region"] + if !ok { + return "", fmt.Errorf("couldn't find attribute region") + } + instanceId, ok := r.Primary.Attributes["instance_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute instance_id") + } + return fmt.Sprintf("%s,%s,%s", projectId, region, instanceId), nil + }, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"flavor", "replicas"}, + }, + // Update + { + ConfigVariables: testConfigInstanceVarsPrivatePreviewMaxUpdated, + Config: fmt.Sprintf("%s\n%s", testutil.NewConfigBuilder().Region(testutil.Region).BuildProviderConfig(), resourceInstancePrivatePreviewMaxConfig), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("stackit_postgresflex_instance.instance", plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeAggregateTestCheckFunc( + // Instance with flavor id + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "project_id", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["project_id"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "instance_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "name", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["name"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["acl"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "network.acl.#", "1"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "network.acl.0", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["acl"])), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "network.access_scope"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "backup_schedule", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["backup_schedule"])), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.id"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.description"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.cpu"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "flavor.ram"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "connection_info.write.host"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "connection_info.write.port"), + resource.TestCheckNoResourceAttr("stackit_postgresflex_instance.instance", "replicas"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "retention_days", retentionDaysDefault), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.class", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["storage_class"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "storage.size", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["storage_size"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "version", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["instance_version"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "region", testutil.Region), + + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "encryption.kek_key_id"), + resource.TestCheckResourceAttrSet("stackit_postgresflex_instance.instance", "encryption.kek_keyring_id"), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "encryption.kek_key_version", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["kek_key_version"])), + resource.TestCheckResourceAttr("stackit_postgresflex_instance.instance", "encryption.service_account", testutil.ConvertConfigVariable(testConfigInstanceVarsPrivatePreviewMaxUpdated["service_account_email"]))), + }, + // Deletion is done by the framework implicitly + }, + }) +} diff --git a/stackit/internal/services/postgresflex/testdata/resource-database-min.tf b/stackit/internal/services/postgresflex/testdata/resource-database-min.tf new file mode 100644 index 000000000..709b84531 --- /dev/null +++ b/stackit/internal/services/postgresflex/testdata/resource-database-min.tf @@ -0,0 +1,43 @@ +variable "project_id" {} +variable "instance_name" {} +variable "username" {} +variable "acl" {} +variable "backup_schedule" {} +variable "flavor_id" {} +variable "storage_class" {} +variable "storage_size" {} +variable "instance_version" {} +variable "roles" {} + +variable "database_name" {} + +# no test candidate, just needed for the testing setup +resource "stackit_postgresflex_instance" "instance" { + project_id = var.project_id + name = var.instance_name + network = { + acl = [var.acl] + } + backup_schedule = var.backup_schedule + flavor_id = var.flavor_id + storage = { + class = var.storage_class + size = var.storage_size + } + version = var.instance_version +} + +resource "stackit_postgresflex_user" "user" { + project_id = stackit_postgresflex_instance.instance.project_id + instance_id = stackit_postgresflex_instance.instance.instance_id + username = var.username + roles = [var.roles] +} + +# test candidate +resource "stackit_postgresflex_database" "database" { + project_id = stackit_postgresflex_instance.instance.project_id + instance_id = stackit_postgresflex_instance.instance.instance_id + owner = stackit_postgresflex_user.user.username + name = var.database_name +} diff --git a/stackit/internal/services/postgresflex/testdata/resource-instance-max.tf b/stackit/internal/services/postgresflex/testdata/resource-instance-max.tf new file mode 100644 index 000000000..1b3a08497 --- /dev/null +++ b/stackit/internal/services/postgresflex/testdata/resource-instance-max.tf @@ -0,0 +1,50 @@ +variable "project_id" {} +variable "name" {} +variable "acl" {} +variable "access_scope" {} +variable "backup_schedule" {} +variable "flavor_id" {} +variable "storage_class" {} +variable "storage_size" {} +variable "instance_version" {} +variable "retention_days" {} +variable "flavor_cpu" {} +variable "flavor_ram" {} +variable "replicas" {} +variable "region" {} + +resource "stackit_postgresflex_instance" "with_flavor_id" { + project_id = var.project_id + name = var.name + network = { + acl = [var.acl] + access_scope = var.access_scope + } + backup_schedule = var.backup_schedule + flavor_id = var.flavor_id + storage = { + class = var.storage_class + size = var.storage_size + } + version = var.instance_version + retention_days = var.retention_days +} + +resource "stackit_postgresflex_instance" "with_flavor" { + project_id = var.project_id + name = var.name + acl = [var.acl] + backup_schedule = var.backup_schedule + flavor = { + cpu = var.flavor_cpu + ram = var.flavor_ram + } + replicas = var.replicas + storage = { + class = var.storage_class + size = var.storage_size + } + version = var.instance_version + retention_days = var.retention_days + region = var.region +} diff --git a/stackit/internal/services/postgresflex/testdata/resource-instance-min.tf b/stackit/internal/services/postgresflex/testdata/resource-instance-min.tf new file mode 100644 index 000000000..99a154b98 --- /dev/null +++ b/stackit/internal/services/postgresflex/testdata/resource-instance-min.tf @@ -0,0 +1,23 @@ +variable "project_id" {} +variable "name" {} +variable "acl" {} +variable "backup_schedule" {} +variable "flavor_id" {} +variable "storage_class" {} +variable "storage_size" {} +variable "instance_version" {} + +resource "stackit_postgresflex_instance" "instance" { + project_id = var.project_id + name = var.name + network = { + acl = [var.acl] + } + backup_schedule = var.backup_schedule + flavor_id = var.flavor_id + storage = { + class = var.storage_class + size = var.storage_size + } + version = var.instance_version +} diff --git a/stackit/internal/services/postgresflex/testdata/resource-instance-private-preview-max.tf b/stackit/internal/services/postgresflex/testdata/resource-instance-private-preview-max.tf new file mode 100644 index 000000000..083bf9e65 --- /dev/null +++ b/stackit/internal/services/postgresflex/testdata/resource-instance-private-preview-max.tf @@ -0,0 +1,62 @@ +variable "project_id" {} +variable "kek_key_version" {} +variable "service_account_email" {} +variable "keyring_display_name" {} +variable "display_name" {} +variable "protection" {} +variable "algorithm" {} +variable "purpose" {} + +variable "name" {} +variable "acl" {} +variable "access_scope" {} +variable "backup_schedule" {} +variable "flavor_id" {} +variable "storage_class" {} +variable "storage_size" {} +variable "instance_version" {} +variable "retention_days" {} +variable "flavor_cpu" {} +variable "flavor_ram" {} +variable "replicas" {} +variable "region" {} + +# no test candidate, just needed for the testing setup +resource "stackit_kms_keyring" "keyring" { + project_id = var.project_id + display_name = var.keyring_display_name +} + +resource "stackit_kms_key" "key" { + project_id = var.project_id + keyring_id = stackit_kms_keyring.keyring.keyring_id + protection = var.protection + algorithm = var.algorithm + display_name = var.display_name + purpose = var.purpose +} + +# test candidate +resource "stackit_postgresflex_instance" "instance" { + project_id = var.project_id + name = var.name + network = { + acl = [var.acl] + access_scope = var.access_scope + } + backup_schedule = var.backup_schedule + flavor_id = var.flavor_id + storage = { + class = var.storage_class + size = var.storage_size + } + version = var.instance_version + retention_days = var.retention_days + + encryption = { + kek_key_id = stackit_kms_key.key.key_id + kek_keyring_id = stackit_kms_keyring.keyring.keyring_id + kek_key_version = var.kek_key_version + service_account = var.service_account_email + } +} \ No newline at end of file diff --git a/stackit/internal/services/postgresflex/testdata/resource-user-min.tf b/stackit/internal/services/postgresflex/testdata/resource-user-min.tf new file mode 100644 index 000000000..3a1a69d9d --- /dev/null +++ b/stackit/internal/services/postgresflex/testdata/resource-user-min.tf @@ -0,0 +1,35 @@ +variable "project_id" {} +variable "name" {} +variable "acl" {} +variable "backup_schedule" {} +variable "flavor_id" {} +variable "storage_class" {} +variable "storage_size" {} +variable "instance_version" {} + +variable "username" {} +variable "roles" {} + +# no test candidate, just needed for the testing setup +resource "stackit_postgresflex_instance" "instance" { + project_id = var.project_id + name = var.name + network = { + acl = [var.acl] + } + backup_schedule = var.backup_schedule + flavor_id = var.flavor_id + storage = { + class = var.storage_class + size = var.storage_size + } + version = var.instance_version +} + +# test candidate +resource "stackit_postgresflex_user" "user" { + project_id = stackit_postgresflex_instance.instance.project_id + instance_id = stackit_postgresflex_instance.instance.instance_id + username = var.username + roles = [var.roles] +} diff --git a/stackit/internal/services/postgresflex/user/datasource.go b/stackit/internal/services/postgresflex/user/datasource.go index 404590ae9..91b0820ed 100644 --- a/stackit/internal/services/postgresflex/user/datasource.go +++ b/stackit/internal/services/postgresflex/user/datasource.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strconv" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" postgresflexUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/postgresflex/utils" @@ -19,7 +20,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" ) // Ensure the implementation satisfies the expected interfaces. @@ -34,9 +35,11 @@ type DataSourceModel struct { ProjectId types.String `tfsdk:"project_id"` Username types.String `tfsdk:"username"` Roles types.Set `tfsdk:"roles"` - Host types.String `tfsdk:"host"` - Port types.Int64 `tfsdk:"port"` - Region types.String `tfsdk:"region"` + // Deprecated: Host is deprecated and will be removed after February 2027 + Host types.String `tfsdk:"host"` + // Deprecated: Port is deprecated and will be removed after February 2027 + Port types.Int32 `tfsdk:"port"` + Region types.String `tfsdk:"region"` } // NewUserDataSource is a helper function to simplify the provider implementation. @@ -120,10 +123,12 @@ func (r *userDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, r Computed: true, }, "host": schema.StringAttribute{ - Computed: true, + DeprecationMessage: "host is deprecated and will be removed after February 2027. The host can be retrieved from the instance `connection_info.write.host`.", + Computed: true, }, - "port": schema.Int64Attribute{ - Computed: true, + "port": schema.Int32Attribute{ + DeprecationMessage: "port is deprecated and will be removed after February 2027. The port can be retrieved from the instance `connection_info.write.port`.", + Computed: true, }, "region": schema.StringAttribute{ // the region cannot be found automatically, so it has to be passed @@ -147,13 +152,20 @@ func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, r projectId := model.ProjectId.ValueString() instanceId := model.InstanceId.ValueString() - userId := model.UserId.ValueString() + userIdStr := model.UserId.ValueString() region := r.providerData.GetRegionWithOverride(model.Region) ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "instance_id", instanceId) - ctx = tflog.SetField(ctx, "user_id", userId) + ctx = tflog.SetField(ctx, "user_id", userIdStr) ctx = tflog.SetField(ctx, "region", region) + // In v2 the ID was a string. This was changed in the v3 API. + userId, err := strconv.ParseInt(userIdStr, 10, 64) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Parsing user ID: %v", err)) + return + } + recordSetResp, err := r.client.DefaultAPI.GetUser(ctx, projectId, region, instanceId, userId).Execute() if err != nil { utils.LogError( @@ -161,7 +173,7 @@ func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, r &resp.Diagnostics, err, "Reading user", - fmt.Sprintf("User with ID %q or instance with ID %q does not exist in project %q.", userId, instanceId, projectId), + fmt.Sprintf("User with ID %q or instance with ID %q does not exist in project %q.", userIdStr, instanceId, projectId), map[int]string{ http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectId), }, @@ -172,8 +184,15 @@ func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, r ctx = core.LogResponse(ctx) + // Deprecated: Legacy mode needed during deprecation period to retrieve all v2 values. Can be removed after February 2027 + instanceResp, err := r.client.DefaultAPI.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Calling get instance API: %v", err)) + return + } + // Map response body to schema and populate Computed attribute values - err = mapDataSourceFields(recordSetResp, &model, region) + err = mapDataSourceFields(recordSetResp, instanceResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Processing API payload: %v", err)) return @@ -188,34 +207,34 @@ func (r *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, r tflog.Info(ctx, "Postgres Flex user read") } -func mapDataSourceFields(userResp *postgresflex.GetUserResponse, model *DataSourceModel, region string) error { - if userResp == nil || userResp.Item == nil { - return fmt.Errorf("response is nil") +func mapDataSourceFields(userResp *postgresflex.GetUserResponse, instanceResp *postgresflex.GetInstanceResponse, model *DataSourceModel, region string) error { + if userResp == nil { + return fmt.Errorf("user response is nil") + } + if instanceResp == nil { + return fmt.Errorf("instance response is nil") } if model == nil { return fmt.Errorf("model input is nil") } - user := userResp.Item var userId string if model.UserId.ValueString() != "" { userId = model.UserId.ValueString() - } else if user.Id != nil { - userId = *user.Id } else { - return fmt.Errorf("user id not present") + userId = strconv.FormatInt(userResp.Id, 10) } model.Id = utils.BuildInternalTerraformId( model.ProjectId.ValueString(), region, model.InstanceId.ValueString(), userId, ) model.UserId = types.StringValue(userId) - model.Username = types.StringPointerValue(user.Username) + model.Username = types.StringValue(userResp.Name) - if user.Roles == nil { + if userResp.Roles == nil { model.Roles = types.SetNull(types.StringType) } else { roles := []attr.Value{} - for _, role := range user.Roles { + for _, role := range userResp.Roles { roles = append(roles, types.StringValue(role)) } rolesSet, diags := types.SetValue(types.StringType, roles) @@ -224,8 +243,8 @@ func mapDataSourceFields(userResp *postgresflex.GetUserResponse, model *DataSour } model.Roles = rolesSet } - model.Host = types.StringPointerValue(user.Host) - model.Port = types.Int64PointerValue(user.Port) + model.Host = types.StringValue(instanceResp.ConnectionInfo.Write.Host) + model.Port = types.Int32Value(instanceResp.ConnectionInfo.Write.Port) model.Region = types.StringValue(region) return nil } diff --git a/stackit/internal/services/postgresflex/user/datasource_test.go b/stackit/internal/services/postgresflex/user/datasource_test.go index 80807a3d1..e6a572583 100644 --- a/stackit/internal/services/postgresflex/user/datasource_test.go +++ b/stackit/internal/services/postgresflex/user/datasource_test.go @@ -6,53 +6,57 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" ) func TestMapDataSourceFields(t *testing.T) { const testRegion = "region" tests := []struct { - description string - input *postgresflex.GetUserResponse - region string - expected DataSourceModel - isValid bool + description string + userResp *postgresflex.GetUserResponse + instanceResp *postgresflex.GetInstanceResponse + region string + expected DataSourceModel + isValid bool }{ { - "default_values", - &postgresflex.GetUserResponse{ - Item: &postgresflex.UserResponse{}, - }, - testRegion, - DataSourceModel{ + description: "default_values", + userResp: &postgresflex.GetUserResponse{}, + instanceResp: &postgresflex.GetInstanceResponse{}, + region: testRegion, + expected: DataSourceModel{ Id: types.StringValue("pid,region,iid,uid"), UserId: types.StringValue("uid"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), - Username: types.StringNull(), + Username: types.StringValue(""), Roles: types.SetNull(types.StringType), - Host: types.StringNull(), - Port: types.Int64Null(), + Host: types.StringValue(""), + Port: types.Int32Value(0), Region: types.StringValue(testRegion), }, - true, + isValid: true, }, { - "simple_values", - &postgresflex.GetUserResponse{ - Item: &postgresflex.UserResponse{ - Roles: []string{ - "role_1", - "role_2", - "", + description: "simple_values", + userResp: &postgresflex.GetUserResponse{ + Roles: []string{ + "role_1", + "role_2", + "", + }, + Name: "username", + }, + instanceResp: &postgresflex.GetInstanceResponse{ + ConnectionInfo: postgresflex.InstanceConnectionInfo{ + Write: postgresflex.InstanceConnectionInfoWrite{ + Host: "host", + Port: 1234, }, - Username: new("username"), - Host: new("host"), - Port: new(int64(1234)), }, }, - testRegion, - DataSourceModel{ + region: testRegion, + expected: DataSourceModel{ Id: types.StringValue("pid,region,iid,uid"), UserId: types.StringValue("uid"), InstanceId: types.StringValue("iid"), @@ -64,58 +68,55 @@ func TestMapDataSourceFields(t *testing.T) { types.StringValue(""), }), Host: types.StringValue("host"), - Port: types.Int64Value(1234), + Port: types.Int32Value(1234), Region: types.StringValue(testRegion), }, - true, + isValid: true, }, { - "null_fields_and_int_conversions", - &postgresflex.GetUserResponse{ - Item: &postgresflex.UserResponse{ - Id: new("uid"), - Roles: []string{}, - Username: nil, - Host: nil, - Port: new(int64(2123456789)), + description: "null_fields_and_int_conversions", + userResp: &postgresflex.GetUserResponse{ + Id: 123, + Roles: []string{}, + Name: "", + }, + instanceResp: &postgresflex.GetInstanceResponse{ + ConnectionInfo: postgresflex.InstanceConnectionInfo{ + Write: postgresflex.InstanceConnectionInfoWrite{ + Host: "", + Port: 2123456789, + }, }, }, - testRegion, - DataSourceModel{ - Id: types.StringValue("pid,region,iid,uid"), - UserId: types.StringValue("uid"), + region: testRegion, + expected: DataSourceModel{ + Id: types.StringValue("pid,region,iid,123"), + UserId: types.StringValue("123"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), - Username: types.StringNull(), + Username: types.StringValue(""), Roles: types.SetValueMust(types.StringType, []attr.Value{}), - Host: types.StringNull(), - Port: types.Int64Value(2123456789), + Host: types.StringValue(""), + Port: types.Int32Value(2123456789), Region: types.StringValue(testRegion), }, - true, + isValid: true, }, { - "nil_response", - nil, - testRegion, - DataSourceModel{}, - false, + description: "nil_response", + userResp: nil, + instanceResp: &postgresflex.GetInstanceResponse{}, + region: testRegion, + expected: DataSourceModel{}, + isValid: false, }, { - "nil_response_2", - &postgresflex.GetUserResponse{}, - testRegion, - DataSourceModel{}, - false, - }, - { - "no_resource_id", - &postgresflex.GetUserResponse{ - Item: &postgresflex.UserResponse{}, - }, - testRegion, - DataSourceModel{}, - false, + description: "nil_response_2", + userResp: &postgresflex.GetUserResponse{}, + instanceResp: nil, + region: testRegion, + expected: DataSourceModel{}, + isValid: false, }, } for _, tt := range tests { @@ -125,7 +126,7 @@ func TestMapDataSourceFields(t *testing.T) { InstanceId: tt.expected.InstanceId, UserId: tt.expected.UserId, } - err := mapDataSourceFields(tt.input, state, tt.region) + err := mapDataSourceFields(tt.userResp, tt.instanceResp, state, tt.region) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } diff --git a/stackit/internal/services/postgresflex/user/resource.go b/stackit/internal/services/postgresflex/user/resource.go index b1942ec34..7db66929b 100644 --- a/stackit/internal/services/postgresflex/user/resource.go +++ b/stackit/internal/services/postgresflex/user/resource.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" + "time" "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" @@ -27,8 +29,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api/wait" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api/wait" ) // Ensure the implementation satisfies the expected interfaces. @@ -47,10 +49,13 @@ type Model struct { Username types.String `tfsdk:"username"` Roles types.Set `tfsdk:"roles"` Password types.String `tfsdk:"password"` - Host types.String `tfsdk:"host"` - Port types.Int64 `tfsdk:"port"` - Uri types.String `tfsdk:"uri"` - Region types.String `tfsdk:"region"` + // Deprecated: Host is deprecated and will be removed after February 2027 + Host types.String `tfsdk:"host"` + // Deprecated: Port is deprecated and will be removed after February 2027 + Port types.Int32 `tfsdk:"port"` + // Deprecated: Uri is deprecated and will be removed after February 2027 + Uri types.String `tfsdk:"uri"` + Region types.String `tfsdk:"region"` // RotateWhenChanged is a map of arbitrary key/value pairs that will force // recreation of the resource when they change, enabling resource rotation based on // external conditions such as a rotating timestamp. Changing this forces a new @@ -177,10 +182,8 @@ func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp }, }, "username": schema.StringAttribute{ - Required: true, - PlanModifiers: []planmodifier.String{ - stringplanmodifier.RequiresReplace(), - }, + Required: true, + PlanModifiers: []planmodifier.String{}, }, "roles": schema.SetAttribute{ Description: descriptions["roles"], @@ -192,14 +195,17 @@ func (r *userResource) Schema(_ context.Context, _ resource.SchemaRequest, resp Sensitive: true, }, "host": schema.StringAttribute{ - Computed: true, + DeprecationMessage: "host is deprecated and will be removed after February 2027. The host can be retrieved from the instance in `connection_info.write.host`.", + Computed: true, }, - "port": schema.Int64Attribute{ - Computed: true, + "port": schema.Int32Attribute{ + DeprecationMessage: "port is deprecated and will be removed after February 2027. The port can be retrieved from the instance in `connection_info.write.port`.", + Computed: true, }, "uri": schema.StringAttribute{ - Computed: true, - Sensitive: true, + DeprecationMessage: "uri is deprecated and will be removed after February 2027.", + Computed: true, + Sensitive: true, }, "region": schema.StringAttribute{ Optional: true, @@ -259,8 +265,21 @@ func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, r core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Creating API payload: %v", err)) return } + // Create new user - userResp, err := r.client.DefaultAPI.CreateUser(ctx, projectId, region, instanceId).CreateUserPayload(*payload).Execute() + // Workaround: The user creation will be tried 5 times. In some cases the instance might be + // in maintenance mode and the user API is temporary unavailable. Usually this is only for 1-2 seconds. + config := utils.RetryConfig{ + Attempts: 5, + Backoff: func(attempt int) time.Duration { + // Wait for every attempt 5 seconds longer. 5s, 10s, 15s and so on + return time.Duration(attempt*5) * time.Second + }, + RetryStatusCodes: []int{ + http.StatusLocked, + }, + } + userResp, err := utils.RetryRequest(ctx, r.client.DefaultAPI.CreateUser(ctx, projectId, region, instanceId).CreateUserPayload(*payload).Execute, config) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Calling API: %v", err)) return @@ -268,15 +287,36 @@ func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, r ctx = core.LogResponse(ctx) - if userResp == nil || userResp.Item == nil || userResp.Item.Id == nil || *userResp.Item.Id == "" { - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", "API didn't return user Id. A user might have been created") + if userResp == nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", "API didn't return response. A user might have been created") + return + } + + // Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler + ctx = utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{ + "project_id": projectId, + "region": region, + "instance_id": instanceId, + "user_id": strconv.FormatInt(userResp.Id, 10), + }) + if resp.Diagnostics.HasError() { + return + } + + getResp, err := wait.CreateUserWaitHandler(ctx, r.client.DefaultAPI, projectId, region, instanceId, userResp.Id).WaitWithContext(ctx) + if err != nil { + return + } + + // Deprecated: Legacy mode needed during deprecation period to retrieve all v2 values. Can be removed after February 2027 + instanceResp, err := r.client.DefaultAPI.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Calling get instance API: %v", err)) return } - userId := *userResp.Item.Id - ctx = tflog.SetField(ctx, "user_id", userId) // Map response body to schema - err = mapFieldsCreate(userResp, &model, region) + err = mapFieldsCreate(userResp, getResp, instanceResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Processing API payload: %v", err)) return @@ -303,8 +343,8 @@ func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp projectId := model.ProjectId.ValueString() instanceId := model.InstanceId.ValueString() - userId := model.UserId.ValueString() - if userId == "" { + userIdStr := model.UserId.ValueString() + if userIdStr == "" { // Resource not yet created; ID is unknown. resp.State.RemoveResource(ctx) return @@ -312,13 +352,19 @@ func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp region := r.providerData.GetRegionWithOverride(model.Region) ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "instance_id", instanceId) - ctx = tflog.SetField(ctx, "user_id", userId) + ctx = tflog.SetField(ctx, "user_id", userIdStr) ctx = tflog.SetField(ctx, "region", region) + // In v2 the ID was a string. This was changed in the v3 API. + userId, err := strconv.ParseInt(userIdStr, 10, 64) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Parsing user ID: %v", err)) + return + } + recordSetResp, err := r.client.DefaultAPI.GetUser(ctx, projectId, region, instanceId, userId).Execute() if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { resp.State.RemoveResource(ctx) return } @@ -328,8 +374,15 @@ func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp ctx = core.LogResponse(ctx) + // Deprecated: Legacy mode needed during deprecation period to retrieve all v2 values. Can be removed after February 2027 + instanceResp, err := r.client.DefaultAPI.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Calling get instance API: %v", err)) + return + } + // Map response body to schema - err = mapFields(recordSetResp, &model, region) + err = mapFields(recordSetResp, instanceResp, &model, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading user", fmt.Sprintf("Processing API payload: %v", err)) return @@ -358,13 +411,20 @@ func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, r projectId := model.ProjectId.ValueString() instanceId := model.InstanceId.ValueString() - userId := model.UserId.ValueString() + userIdStr := model.UserId.ValueString() region := model.Region.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "instance_id", instanceId) - ctx = tflog.SetField(ctx, "user_id", userId) + ctx = tflog.SetField(ctx, "user_id", userIdStr) ctx = tflog.SetField(ctx, "region", region) + // In v2 the ID was a string. This was changed in the v3 API. + userId, err := strconv.ParseInt(userIdStr, 10, 64) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", fmt.Sprintf("Parsing user ID: %v", err)) + return + } + // Retrieve values from state var stateModel Model diags = req.State.Get(ctx, &stateModel) @@ -390,7 +450,19 @@ func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, r } // Update existing instance - err = r.client.DefaultAPI.UpdateUser(ctx, projectId, region, instanceId, userId).UpdateUserPayload(*payload).Execute() + // Workaround: The user update will be tried 5 times. In some cases the instance might be + // in maintenance mode and the user API is temporary unavailable. Usually this is only for 1-2 seconds. + config := utils.RetryConfig{ + Attempts: 5, + Backoff: func(attempt int) time.Duration { + // Wait for every attempt 5 seconds longer. 5s, 10s, 15s and so on + return time.Duration(attempt*5) * time.Second + }, + RetryStatusCodes: []int{ + http.StatusLocked, + }, + } + err = utils.RetryRequestWithoutResponse(ctx, r.client.DefaultAPI.PartialUpdateUser(ctx, projectId, region, instanceId, userId).PartialUpdateUserPayload(*payload).Execute, config) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", err.Error()) return @@ -404,8 +476,15 @@ func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, r return } + // Deprecated: Legacy mode needed during deprecation period to retrieve all v2 values. Can be removed after February 2027 + instanceResp, err := r.client.DefaultAPI.GetInstance(ctx, projectId, region, instanceId).Execute() + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating user", fmt.Sprintf("Calling get instance API: %v", err)) + return + } + // Map response body to schema - err = mapFields(userResp, &stateModel, region) + err = mapFields(userResp, instanceResp, &stateModel, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", fmt.Sprintf("Processing API payload: %v", err)) return @@ -434,18 +513,37 @@ func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, r projectId := model.ProjectId.ValueString() instanceId := model.InstanceId.ValueString() - userId := model.UserId.ValueString() + userIdStr := model.UserId.ValueString() region := model.Region.ValueString() ctx = tflog.SetField(ctx, "project_id", projectId) ctx = tflog.SetField(ctx, "instance_id", instanceId) - ctx = tflog.SetField(ctx, "user_id", userId) + ctx = tflog.SetField(ctx, "user_id", userIdStr) ctx = tflog.SetField(ctx, "region", region) + // In v2 the ID was a string. This was changed in the v3 API. + userId, err := strconv.ParseInt(userIdStr, 10, 64) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting user", fmt.Sprintf("Parsing user ID: %v", err)) + return + } + // Delete existing record set - err := r.client.DefaultAPI.DeleteUser(ctx, projectId, region, instanceId, userId).Execute() + // Workaround: The user delete will be tried 5 times. In some cases the instance might be + // in maintenance mode and the user API is temporary unavailable. Usually this is only for 1-2 seconds. + config := utils.RetryConfig{ + Attempts: 5, + Backoff: func(attempt int) time.Duration { + // Wait for every attempt 5 seconds longer. 5s, 10s, 15s and so on + return time.Duration(attempt*5) * time.Second + }, + RetryStatusCodes: []int{ + http.StatusLocked, + }, + } + err = utils.RetryRequestWithoutResponse(ctx, r.client.DefaultAPI.DeleteUser(ctx, projectId, region, instanceId, userId).Execute, config) if err != nil { - var oapiErr *oapierror.GenericOpenAPIError - if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound { + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok && oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) return } core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting user", fmt.Sprintf("Calling API: %v", err)) @@ -485,35 +583,33 @@ func (r *userResource) ImportState(ctx context.Context, req resource.ImportState tflog.Info(ctx, "Postgresflex user state imported") } -func mapFieldsCreate(userResp *postgresflex.CreateUserResponse, model *Model, region string) error { - if userResp == nil || userResp.Item == nil { - return fmt.Errorf("response is nil") +func mapFieldsCreate(userResp *postgresflex.CreateUserResponse, getUserResp *postgresflex.GetUserResponse, instanceResp *postgresflex.GetInstanceResponse, model *Model, region string) error { + if userResp == nil { + return fmt.Errorf("create response is nil") + } + if getUserResp == nil { + return fmt.Errorf("get response is nil") + } + if instanceResp == nil { + return fmt.Errorf("instance response is nil") } if model == nil { return fmt.Errorf("model input is nil") } - user := userResp.Item - if user.Id == nil { - return fmt.Errorf("user id not present") - } - userId := *user.Id + userId := strconv.FormatInt(userResp.Id, 10) model.Id = utils.BuildInternalTerraformId( model.ProjectId.ValueString(), region, model.InstanceId.ValueString(), userId, ) model.UserId = types.StringValue(userId) - model.Username = types.StringPointerValue(user.Username) - - if user.Password == nil { - return fmt.Errorf("user password not present") - } - model.Password = types.StringValue(*user.Password) + model.Username = types.StringValue(userResp.Name) + model.Password = types.StringValue(userResp.Password) - if user.Roles == nil { + if getUserResp.Roles == nil { model.Roles = types.SetNull(types.StringType) } else { roles := []attr.Value{} - for _, role := range user.Roles { + for _, role := range getUserResp.Roles { roles = append(roles, types.StringValue(role)) } rolesSet, diags := types.SetValue(types.StringType, roles) @@ -522,41 +618,41 @@ func mapFieldsCreate(userResp *postgresflex.CreateUserResponse, model *Model, re } model.Roles = rolesSet } - model.Host = types.StringPointerValue(user.Host) - model.Port = types.Int64PointerValue(user.Port) - model.Uri = types.StringPointerValue(user.Uri) + model.Host = types.StringValue(instanceResp.ConnectionInfo.Write.Host) + model.Port = types.Int32Value(instanceResp.ConnectionInfo.Write.Port) + model.Uri = types.StringValue(fmt.Sprintf("postgresql://%s:%s@%s:%d/stackit", userResp.Name, userResp.Password, instanceResp.ConnectionInfo.Write.Host, instanceResp.ConnectionInfo.Write.Port)) model.Region = types.StringValue(region) return nil } -func mapFields(userResp *postgresflex.GetUserResponse, model *Model, region string) error { - if userResp == nil || userResp.Item == nil { - return fmt.Errorf("response is nil") +func mapFields(userResp *postgresflex.GetUserResponse, instanceResp *postgresflex.GetInstanceResponse, model *Model, region string) error { + if userResp == nil { + return fmt.Errorf("user response is nil") + } + if instanceResp == nil { + return fmt.Errorf("instance response is nil") } if model == nil { return fmt.Errorf("model input is nil") } - user := userResp.Item var userId string if model.UserId.ValueString() != "" { userId = model.UserId.ValueString() - } else if user.Id != nil { - userId = *user.Id } else { - return fmt.Errorf("user id not present") + userId = strconv.FormatInt(userResp.Id, 10) } model.Id = utils.BuildInternalTerraformId( model.ProjectId.ValueString(), region, model.InstanceId.ValueString(), userId, ) model.UserId = types.StringValue(userId) - model.Username = types.StringPointerValue(user.Username) + model.Username = types.StringValue(userResp.Name) - if user.Roles == nil { + if userResp.Roles == nil { model.Roles = types.SetNull(types.StringType) } else { roles := []attr.Value{} - for _, role := range user.Roles { + for _, role := range userResp.Roles { roles = append(roles, types.StringValue(role)) } rolesSet, diags := types.SetValue(types.StringType, roles) @@ -565,8 +661,8 @@ func mapFields(userResp *postgresflex.GetUserResponse, model *Model, region stri } model.Roles = rolesSet } - model.Host = types.StringPointerValue(user.Host) - model.Port = types.Int64PointerValue(user.Port) + model.Host = types.StringValue(instanceResp.ConnectionInfo.Write.Host) + model.Port = types.Int32Value(instanceResp.ConnectionInfo.Write.Port) model.Region = types.StringValue(region) return nil } @@ -580,12 +676,12 @@ func toCreatePayload(model *Model, roles []string) (*postgresflex.CreateUserPayl } return &postgresflex.CreateUserPayload{ - Roles: roles, - Username: conversion.StringValueToPointer(model.Username), + Roles: roles, + Name: model.Username.ValueString(), }, nil } -func toUpdatePayload(model *Model, roles []string) (*postgresflex.UpdateUserPayload, error) { +func toUpdatePayload(model *Model, roles []string) (*postgresflex.PartialUpdateUserPayload, error) { if model == nil { return nil, fmt.Errorf("nil model") } @@ -593,7 +689,8 @@ func toUpdatePayload(model *Model, roles []string) (*postgresflex.UpdateUserPayl return nil, fmt.Errorf("nil roles") } - return &postgresflex.UpdateUserPayload{ + return &postgresflex.PartialUpdateUserPayload{ + Name: model.Username.ValueStringPointer(), Roles: roles, }, nil } diff --git a/stackit/internal/services/postgresflex/user/resource_test.go b/stackit/internal/services/postgresflex/user/resource_test.go index cff938285..c6ab6f135 100644 --- a/stackit/internal/services/postgresflex/user/resource_test.go +++ b/stackit/internal/services/postgresflex/user/resource_test.go @@ -1,69 +1,91 @@ package postgresflex import ( + "fmt" "testing" "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/types" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" ) func TestMapFieldsCreate(t *testing.T) { - const testRegion = "region" + const ( + projectId = "pid" + instanceId = "iid" + testRegion = "region" + userId = 123 + userIdStr = "123" + ) + var id = fmt.Sprintf("%s,%s,%s,%s", projectId, testRegion, instanceId, userIdStr) tests := []struct { - description string - input *postgresflex.CreateUserResponse - region string - expected Model - isValid bool + description string + createUserResp *postgresflex.CreateUserResponse + userResp *postgresflex.GetUserResponse + instanceResp *postgresflex.GetInstanceResponse + region string + expected Model + isValid bool }{ { - "default_values", - &postgresflex.CreateUserResponse{ - Item: &postgresflex.User{ - Id: new("uid"), - Password: new(""), + description: "default_values", + createUserResp: &postgresflex.CreateUserResponse{ + Id: userId, + Password: "", + }, + userResp: &postgresflex.GetUserResponse{}, + instanceResp: &postgresflex.GetInstanceResponse{ + ConnectionInfo: postgresflex.InstanceConnectionInfo{ + Write: postgresflex.InstanceConnectionInfoWrite{ + Host: "localhost", + Port: 5432, + }, }, }, - testRegion, - Model{ - Id: types.StringValue("pid,region,iid,uid"), - UserId: types.StringValue("uid"), - InstanceId: types.StringValue("iid"), - ProjectId: types.StringValue("pid"), - Username: types.StringNull(), + region: testRegion, + expected: Model{ + Id: types.StringValue(id), + UserId: types.StringValue(userIdStr), + InstanceId: types.StringValue(instanceId), + ProjectId: types.StringValue(projectId), + Username: types.StringValue(""), Roles: types.SetNull(types.StringType), Password: types.StringValue(""), - Host: types.StringNull(), - Port: types.Int64Null(), - Uri: types.StringNull(), + Host: types.StringValue("localhost"), + Port: types.Int32Value(5432), + Uri: types.StringValue("postgresql://:@localhost:5432/stackit"), Region: types.StringValue(testRegion), RotateWhenChanged: types.MapNull(types.StringType), }, - true, + isValid: true, }, { - "simple_values", - &postgresflex.CreateUserResponse{ - Item: &postgresflex.User{ - Id: new("uid"), - Roles: []string{ - "role_1", - "role_2", - "", + description: "simple_values", + createUserResp: &postgresflex.CreateUserResponse{ + Id: 123, + Name: "username", + Password: "password", + }, + userResp: &postgresflex.GetUserResponse{ + Roles: []string{ + "role_1", + "role_2", + "", + }, + }, + instanceResp: &postgresflex.GetInstanceResponse{ + ConnectionInfo: postgresflex.InstanceConnectionInfo{ + Write: postgresflex.InstanceConnectionInfoWrite{ + Host: "host", + Port: 1234, }, - Username: new("username"), - Password: new("password"), - Host: new("host"), - Port: new(int64(1234)), - Uri: new("uri"), }, }, - testRegion, - Model{ - Id: types.StringValue("pid,region,iid,uid"), - UserId: types.StringValue("uid"), + region: testRegion, + expected: Model{ + Id: types.StringValue("pid,region,iid,123"), + UserId: types.StringValue("123"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), Username: types.StringValue("username"), @@ -74,76 +96,65 @@ func TestMapFieldsCreate(t *testing.T) { }), Password: types.StringValue("password"), Host: types.StringValue("host"), - Port: types.Int64Value(1234), - Uri: types.StringValue("uri"), + Port: types.Int32Value(1234), + Uri: types.StringValue("postgresql://username:password@host:1234/stackit"), Region: types.StringValue(testRegion), RotateWhenChanged: types.MapNull(types.StringType), }, - true, + isValid: true, }, { - "null_fields_and_int_conversions", - &postgresflex.CreateUserResponse{ - Item: &postgresflex.User{ - Id: new("uid"), - Roles: []string{}, - Username: nil, - Password: new(""), - Host: nil, - Port: new(int64(2123456789)), - Uri: nil, + description: "null_fields_and_int_conversions", + createUserResp: &postgresflex.CreateUserResponse{ + Id: 123, + Name: "", + Password: "", + }, + userResp: &postgresflex.GetUserResponse{ + Roles: []string{}, + }, + instanceResp: &postgresflex.GetInstanceResponse{ + ConnectionInfo: postgresflex.InstanceConnectionInfo{ + Write: postgresflex.InstanceConnectionInfoWrite{ + Host: "", + Port: 2123456789, + }, }, }, - testRegion, - Model{ - Id: types.StringValue("pid,region,iid,uid"), - UserId: types.StringValue("uid"), + region: testRegion, + expected: Model{ + Id: types.StringValue("pid,region,iid,123"), + UserId: types.StringValue("123"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), - Username: types.StringNull(), + Username: types.StringValue(""), Roles: types.SetValueMust(types.StringType, []attr.Value{}), Password: types.StringValue(""), - Host: types.StringNull(), - Port: types.Int64Value(2123456789), - Uri: types.StringNull(), + Host: types.StringValue(""), + Port: types.Int32Value(2123456789), + Uri: types.StringValue("postgresql://:@:2123456789/stackit"), Region: types.StringValue(testRegion), RotateWhenChanged: types.MapNull(types.StringType), }, - true, - }, - { - "nil_response", - nil, - testRegion, - Model{}, - false, + isValid: true, }, { - "nil_response_2", - &postgresflex.CreateUserResponse{}, - testRegion, - Model{}, - false, + description: "nil_response", + createUserResp: nil, + userResp: &postgresflex.GetUserResponse{}, + instanceResp: &postgresflex.GetInstanceResponse{}, + region: testRegion, + expected: Model{}, + isValid: false, }, { - "no_resource_id", - &postgresflex.CreateUserResponse{ - Item: &postgresflex.User{}, - }, - testRegion, - Model{}, - false, - }, - { - "no_password", - &postgresflex.CreateUserResponse{ - Item: &postgresflex.User{ - Id: new("uid"), - }, - }, - testRegion, - Model{}, - false, + description: "nil_response_2", + createUserResp: &postgresflex.CreateUserResponse{}, + userResp: nil, + instanceResp: &postgresflex.GetInstanceResponse{}, + region: testRegion, + expected: Model{}, + isValid: false, }, } for _, tt := range tests { @@ -153,7 +164,7 @@ func TestMapFieldsCreate(t *testing.T) { InstanceId: tt.expected.InstanceId, RotateWhenChanged: types.MapNull(types.StringType), } - err := mapFieldsCreate(tt.input, state, tt.region) + err := mapFieldsCreate(tt.createUserResp, tt.userResp, tt.instanceResp, state, tt.region) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -173,48 +184,52 @@ func TestMapFieldsCreate(t *testing.T) { func TestMapFields(t *testing.T) { const testRegion = "region" tests := []struct { - description string - input *postgresflex.GetUserResponse - region string - expected Model - isValid bool + description string + userResp *postgresflex.GetUserResponse + instanceResp *postgresflex.GetInstanceResponse + region string + expected Model + isValid bool }{ { - "default_values", - &postgresflex.GetUserResponse{ - Item: &postgresflex.UserResponse{}, - }, - testRegion, - Model{ + description: "default_values", + userResp: &postgresflex.GetUserResponse{}, + instanceResp: &postgresflex.GetInstanceResponse{}, + region: testRegion, + expected: Model{ Id: types.StringValue("pid,region,iid,uid"), UserId: types.StringValue("uid"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), - Username: types.StringNull(), + Username: types.StringValue(""), Roles: types.SetNull(types.StringType), - Host: types.StringNull(), - Port: types.Int64Null(), + Host: types.StringValue(""), + Port: types.Int32Value(0), Region: types.StringValue(testRegion), RotateWhenChanged: types.MapNull(types.StringType), }, - true, + isValid: true, }, { - "simple_values", - &postgresflex.GetUserResponse{ - Item: &postgresflex.UserResponse{ - Roles: []string{ - "role_1", - "role_2", - "", + description: "simple_values", + userResp: &postgresflex.GetUserResponse{ + Roles: []string{ + "role_1", + "role_2", + "", + }, + Name: "username", + }, + instanceResp: &postgresflex.GetInstanceResponse{ + ConnectionInfo: postgresflex.InstanceConnectionInfo{ + Write: postgresflex.InstanceConnectionInfoWrite{ + Host: "host", + Port: 1234, }, - Username: new("username"), - Host: new("host"), - Port: new(int64(1234)), }, }, - testRegion, - Model{ + region: testRegion, + expected: Model{ Id: types.StringValue("pid,region,iid,uid"), UserId: types.StringValue("uid"), InstanceId: types.StringValue("iid"), @@ -226,60 +241,57 @@ func TestMapFields(t *testing.T) { types.StringValue(""), }), Host: types.StringValue("host"), - Port: types.Int64Value(1234), + Port: types.Int32Value(1234), Region: types.StringValue(testRegion), RotateWhenChanged: types.MapNull(types.StringType), }, - true, + isValid: true, }, { - "null_fields_and_int_conversions", - &postgresflex.GetUserResponse{ - Item: &postgresflex.UserResponse{ - Id: new("uid"), - Roles: []string{}, - Username: nil, - Host: nil, - Port: new(int64(2123456789)), + description: "null_fields_and_int_conversions", + userResp: &postgresflex.GetUserResponse{ + Id: 123, + Roles: []string{}, + Name: "", + }, + instanceResp: &postgresflex.GetInstanceResponse{ + ConnectionInfo: postgresflex.InstanceConnectionInfo{ + Write: postgresflex.InstanceConnectionInfoWrite{ + Host: "", + Port: 2123456789, + }, }, }, - testRegion, - Model{ + region: testRegion, + expected: Model{ Id: types.StringValue("pid,region,iid,uid"), UserId: types.StringValue("uid"), InstanceId: types.StringValue("iid"), ProjectId: types.StringValue("pid"), - Username: types.StringNull(), + Username: types.StringValue(""), Roles: types.SetValueMust(types.StringType, []attr.Value{}), - Host: types.StringNull(), - Port: types.Int64Value(2123456789), + Host: types.StringValue(""), + Port: types.Int32Value(2123456789), Region: types.StringValue(testRegion), RotateWhenChanged: types.MapNull(types.StringType), }, - true, + isValid: true, }, { - "nil_response", - nil, - testRegion, - Model{}, - false, - }, - { - "nil_response_2", - &postgresflex.GetUserResponse{}, - testRegion, - Model{}, - false, + description: "nil_response", + userResp: nil, + instanceResp: &postgresflex.GetInstanceResponse{}, + region: testRegion, + expected: Model{}, + isValid: false, }, { - "no_resource_id", - &postgresflex.GetUserResponse{ - Item: &postgresflex.UserResponse{}, - }, - testRegion, - Model{}, - false, + description: "nil_response_2", + userResp: &postgresflex.GetUserResponse{}, + instanceResp: nil, + region: testRegion, + expected: Model{}, + isValid: false, }, } for _, tt := range tests { @@ -290,7 +302,7 @@ func TestMapFields(t *testing.T) { UserId: tt.expected.UserId, RotateWhenChanged: types.MapNull(types.StringType), } - err := mapFields(tt.input, state, tt.region) + err := mapFields(tt.userResp, tt.instanceResp, state, tt.region) if !tt.isValid && err == nil { t.Fatalf("Should have failed") } @@ -320,8 +332,8 @@ func TestToCreatePayload(t *testing.T) { &Model{}, []string{}, &postgresflex.CreateUserPayload{ - Roles: []string{}, - Username: nil, + Roles: []string{}, + Name: "", }, true, }, @@ -339,7 +351,7 @@ func TestToCreatePayload(t *testing.T) { "role_1", "role_2", }, - Username: new("username"), + Name: "username", }, true, }, @@ -355,7 +367,7 @@ func TestToCreatePayload(t *testing.T) { Roles: []string{ "", }, - Username: nil, + Name: "", }, true, }, @@ -398,14 +410,14 @@ func TestToUpdatePayload(t *testing.T) { description string input *Model inputRoles []string - expected *postgresflex.UpdateUserPayload + expected *postgresflex.PartialUpdateUserPayload isValid bool }{ { "default_values", &Model{}, []string{}, - &postgresflex.UpdateUserPayload{ + &postgresflex.PartialUpdateUserPayload{ Roles: []string{}, }, true, @@ -419,7 +431,8 @@ func TestToUpdatePayload(t *testing.T) { "role_1", "role_2", }, - &postgresflex.UpdateUserPayload{ + &postgresflex.PartialUpdateUserPayload{ + Name: new("username"), Roles: []string{ "role_1", "role_2", @@ -435,7 +448,7 @@ func TestToUpdatePayload(t *testing.T) { []string{ "", }, - &postgresflex.UpdateUserPayload{ + &postgresflex.PartialUpdateUserPayload{ Roles: []string{ "", }, diff --git a/stackit/internal/services/postgresflex/utils/util.go b/stackit/internal/services/postgresflex/utils/util.go index 71a236a87..765aa5824 100644 --- a/stackit/internal/services/postgresflex/utils/util.go +++ b/stackit/internal/services/postgresflex/utils/util.go @@ -7,7 +7,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/stackitcloud/stackit-sdk-go/core/config" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" diff --git a/stackit/internal/services/postgresflex/utils/util_test.go b/stackit/internal/services/postgresflex/utils/util_test.go index b1e1ac1dc..b24b102ef 100644 --- a/stackit/internal/services/postgresflex/utils/util_test.go +++ b/stackit/internal/services/postgresflex/utils/util_test.go @@ -10,7 +10,7 @@ import ( sdkClients "github.com/stackitcloud/stackit-sdk-go/core/clients" "github.com/stackitcloud/stackit-sdk-go/core/config" - postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v2api" + postgresflex "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/v3api" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" diff --git a/stackit/internal/services/sfs/resourcepool/resource.go b/stackit/internal/services/sfs/resourcepool/resource.go index 18e0f4f7a..16fdf3579 100644 --- a/stackit/internal/services/sfs/resourcepool/resource.go +++ b/stackit/internal/services/sfs/resourcepool/resource.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" "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" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" @@ -236,7 +237,7 @@ func (r *resourcePoolResource) Schema(_ context.Context, _ resource.SchemaReques Description: "Name of the snapshot policy.", Computed: true, PlanModifiers: []planmodifier.String{ - stringplanmodifierUtils.UseStateForUnknownIf(stringplanmodifierUtils.StringChanged, "id", "sets `UseStateForUnknown` only if `id` has not changed"), + stringplanmodifierUtils.UseStateForUnknownIf(stringplanmodifierUtils.StringUnchanged(path.Root("snapshot_policy").AtName("id")), "sets `UseStateForUnknown` only if `id` has not changed"), }, }, }, diff --git a/stackit/internal/services/ske/cluster/resource.go b/stackit/internal/services/ske/cluster/resource.go index d6ffb4b4c..f6dca5910 100644 --- a/stackit/internal/services/ske/cluster/resource.go +++ b/stackit/internal/services/ske/cluster/resource.go @@ -460,7 +460,7 @@ func (r *clusterResource) Schema(_ context.Context, _ resource.SchemaRequest, re Description: "Full Kubernetes version used. For example, if 1.22 was set in `kubernetes_version_min`, this value may result to 1.22.15. " + SKEUpdateDoc, Computed: true, PlanModifiers: []planmodifier.String{ - stringplanmodifierUtils.UseStateForUnknownIf(stringplanmodifierUtils.StringChanged, "kubernetes_version_min", "sets `UseStateForUnknown` only if `kubernetes_min_version` has not changed"), + stringplanmodifierUtils.UseStateForUnknownIf(stringplanmodifierUtils.StringUnchanged(path.Root("kubernetes_version_min")), "sets `UseStateForUnknown` only if `kubernetes_min_version` has not changed"), }, }, "egress_address_ranges": schema.ListAttribute{ @@ -551,7 +551,7 @@ func (r *clusterResource) Schema(_ context.Context, _ resource.SchemaRequest, re Description: "Full OS image version used. For example, if 3815.2 was set in `os_version_min`, this value may result to 3815.2.2. " + SKEUpdateDoc, Computed: true, PlanModifiers: []planmodifier.String{ - stringplanmodifierUtils.UseStateForUnknownIf(stringplanmodifierUtils.StringChanged, "os_version_min", "sets `UseStateForUnknown` only if `os_version_min` has not changed"), + stringplanmodifierUtils.UseStateForUnknownIf(stringplanmodifierUtils.StringUnchanged(path.Root("os_version_min")), "sets `UseStateForUnknown` only if `os_version_min` has not changed"), }, }, "volume_type": schema.StringAttribute{ diff --git a/stackit/internal/services/sqlserverflex/instance/resource.go b/stackit/internal/services/sqlserverflex/instance/resource.go index 71a9a5ce0..c905cf48b 100644 --- a/stackit/internal/services/sqlserverflex/instance/resource.go +++ b/stackit/internal/services/sqlserverflex/instance/resource.go @@ -364,7 +364,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.List{ - listplanmodifier2.UseStateForUnknownIf(listplanmodifier2.ListChanged(path.Root("network").AtName("acl")), "sets `UseStateForUnknown` only if `network.acl` has not changed"), + listplanmodifier2.UseStateForUnknownIf(listplanmodifier2.ListUnchanged(path.Root("network").AtName("acl")), "sets `UseStateForUnknown` only if `network.acl` has not changed"), }, Validators: []validator.List{ listvalidator.ConflictsWith( @@ -427,7 +427,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Computed: true, Optional: true, PlanModifiers: []planmodifier.Object{ - objectplanmodifier2.UseStateForUnknownIf(objectplanmodifier2.ListChanged(path.Root("acl")), "sets `UseStateForUnknown` only if `acl` has not changed"), + objectplanmodifier2.UseStateForUnknownIf(objectplanmodifier2.ListUnchanged(path.Root("acl")), "sets `UseStateForUnknown` only if `acl` has not changed"), }, Attributes: map[string]schema.Attribute{ "access_scope": schema.StringAttribute{ @@ -445,7 +445,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.List{ - listplanmodifier2.UseStateForUnknownIf(listplanmodifier2.ListChanged(path.Root("acl")), "sets `UseStateForUnknown` only if `acl` has not changed"), + listplanmodifier2.UseStateForUnknownIf(listplanmodifier2.ListUnchanged(path.Root("acl")), "sets `UseStateForUnknown` only if `acl` has not changed"), }, Validators: []validator.List{ listvalidator.ConflictsWith( @@ -511,7 +511,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.Object{ - objectplanmodifier2.UseStateForUnknownIf(objectplanmodifier2.Int32Changed(path.Root("retention_days")), "sets `UseStateForUnknown` only if `retention_days` has not changed"), + objectplanmodifier2.UseStateForUnknownIf(objectplanmodifier2.Int32Unchanged(path.Root("retention_days")), "sets `UseStateForUnknown` only if `retention_days` has not changed"), }, Attributes: map[string]schema.Attribute{ "edition": schema.StringAttribute{ @@ -526,7 +526,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.Int32{ - int32planmodifier2.UseStateForUnknownIf(int32planmodifier2.Int32Changed(path.Root("retention_days")), "sets `UseStateForUnknown` only if `retention_days` has not changed"), + int32planmodifier2.UseStateForUnknownIf(int32planmodifier2.Int32Unchanged(path.Root("retention_days")), "sets `UseStateForUnknown` only if `retention_days` has not changed"), }, Validators: []validator.Int32{ int32validator.ConflictsWith( @@ -541,7 +541,7 @@ func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, r Optional: true, Computed: true, PlanModifiers: []planmodifier.Int32{ - int32planmodifier2.UseStateForUnknownIf(int32planmodifier2.Int32Changed(path.Root("options").AtName("retention_days")), "sets `UseStateForUnknown` only if `options.retention_days` has not changed"), + int32planmodifier2.UseStateForUnknownIf(int32planmodifier2.Int32Unchanged(path.Root("options").AtName("retention_days")), "sets `UseStateForUnknown` only if `options.retention_days` has not changed"), }, Validators: []validator.Int32{ int32validator.ConflictsWith( @@ -1041,7 +1041,9 @@ func toCreatePayload(model *Model, acl []string, flavor *flavorModel, storage *s if err != nil { return nil, err } - networkPayload.AccessScope = (*sqlserverflex.InstanceNetworkAccessScope)(network.AccessScope.ValueStringPointer()) + if !(network.AccessScope.IsNull() || network.AccessScope.IsUnknown()) { + networkPayload.AccessScope = (*sqlserverflex.InstanceNetworkAccessScope)(network.AccessScope.ValueStringPointer()) + } } else { // TODO: Return here an error after the deprecation period. During the deprecation period, we set here an empty ACL to catch the breaking change from v2 -> v3 api. networkPayload.Acl = []string{} diff --git a/stackit/internal/utils/planmodifiers/int32planmodifier/use_state_for_unknown_if.go b/stackit/internal/utils/planmodifiers/int32planmodifier/use_state_for_unknown_if.go index 3a1dd58e0..151112947 100644 --- a/stackit/internal/utils/planmodifiers/int32planmodifier/use_state_for_unknown_if.go +++ b/stackit/internal/utils/planmodifiers/int32planmodifier/use_state_for_unknown_if.go @@ -70,8 +70,8 @@ func (m useStateForUnknownIf) PlanModifyInt32(ctx context.Context, req planmodif } } -// Int32Changed sets UseStateForUnkown to true if the attribute's planned value matches the current state -func Int32Changed(attributePath path.Path) UseStateForUnknownIfFunc { +// Int32Unchanged sets UseStateForUnkown to true if the attribute's planned value matches the current state +func Int32Unchanged(attributePath path.Path) UseStateForUnknownIfFunc { return func(ctx context.Context, request planmodifier.Int32Request, response *UseStateForUnknownFuncResponse) { var attributePlan types.Int32 diags := request.Plan.GetAttribute(ctx, attributePath, &attributePlan) diff --git a/stackit/internal/utils/planmodifiers/int64planmodifier/use_state_for_unknown_if.go b/stackit/internal/utils/planmodifiers/int64planmodifier/use_state_for_unknown_if.go index d8ea31d7c..fcb553448 100644 --- a/stackit/internal/utils/planmodifiers/int64planmodifier/use_state_for_unknown_if.go +++ b/stackit/internal/utils/planmodifiers/int64planmodifier/use_state_for_unknown_if.go @@ -71,8 +71,8 @@ func (m useStateForUnknownIf) PlanModifyInt64(ctx context.Context, req planmodif } } -// Int64Changed sets UseStateForUnkown to true if the attribute's planned value matches the current state -func Int64Changed(ctx context.Context, attributeName string, request planmodifier.Int64Request, response *UseStateForUnknownFuncResponse) { // nolint:gocritic // function signature required by Terraform +// Int64Unchanged sets UseStateForUnkown to true if the attribute's planned value matches the current state +func Int64Unchanged(ctx context.Context, attributeName string, request planmodifier.Int64Request, response *UseStateForUnknownFuncResponse) { // nolint:gocritic // function signature required by Terraform dependencyPath := request.Path.ParentPath().AtName(attributeName) var attributePlan types.Int64 diff --git a/stackit/internal/utils/planmodifiers/listplanmodifier/use_state_for_unknown_if.go b/stackit/internal/utils/planmodifiers/listplanmodifier/use_state_for_unknown_if.go index 09cc327bf..65a44ca94 100644 --- a/stackit/internal/utils/planmodifiers/listplanmodifier/use_state_for_unknown_if.go +++ b/stackit/internal/utils/planmodifiers/listplanmodifier/use_state_for_unknown_if.go @@ -70,8 +70,8 @@ func (m useStateForUnknownIf) PlanModifyList(ctx context.Context, req planmodifi } } -// ListChanged sets UseStateForUnkown to true if the attribute's planned value matches the current state -func ListChanged(attributePath path.Path) UseStateForUnknownIfFunc { +// ListUnchanged sets UseStateForUnkown to true if the attribute's planned value matches the current state +func ListUnchanged(attributePath path.Path) UseStateForUnknownIfFunc { return func(ctx context.Context, request planmodifier.ListRequest, response *UseStateForUnknownFuncResponse) { var attributePlan types.List diags := request.Plan.GetAttribute(ctx, attributePath, &attributePlan) diff --git a/stackit/internal/utils/planmodifiers/objectplanmodifier/use_state_for_unknown_if.go b/stackit/internal/utils/planmodifiers/objectplanmodifier/use_state_for_unknown_if.go index 941807b3a..dbd762f4f 100644 --- a/stackit/internal/utils/planmodifiers/objectplanmodifier/use_state_for_unknown_if.go +++ b/stackit/internal/utils/planmodifiers/objectplanmodifier/use_state_for_unknown_if.go @@ -70,8 +70,8 @@ func (m useStateForUnknownIf) PlanModifyObject(ctx context.Context, req planmodi } } -// Int32Changed sets UseStateForUnkown to true if the attribute's planned value matches the current state -func Int32Changed(attributePath path.Path) UseStateForUnknownIfFunc { +// Int32Unchanged sets UseStateForUnkown to true if the attribute's planned value matches the current state +func Int32Unchanged(attributePath path.Path) UseStateForUnknownIfFunc { return func(ctx context.Context, request planmodifier.ObjectRequest, response *UseStateForUnknownFuncResponse) { var attributePlan types.Int32 diags := request.Plan.GetAttribute(ctx, attributePath, &attributePlan) @@ -94,8 +94,8 @@ func Int32Changed(attributePath path.Path) UseStateForUnknownIfFunc { } } -// ListChanged sets UseStateForUnkown to true if the attribute's planned value matches the current state -func ListChanged(attributePath path.Path) UseStateForUnknownIfFunc { +// ListUnchanged sets UseStateForUnkown to true if the attribute's planned value matches the current state +func ListUnchanged(attributePath path.Path) UseStateForUnknownIfFunc { return func(ctx context.Context, request planmodifier.ObjectRequest, response *UseStateForUnknownFuncResponse) { var attributePlan types.List diags := request.Plan.GetAttribute(ctx, attributePath, &attributePlan) diff --git a/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if.go b/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if.go index 7b5568702..0cb05a9af 100644 --- a/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if.go +++ b/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if.go @@ -4,6 +4,7 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -14,20 +15,18 @@ type UseStateForUnknownFuncResponse struct { } // UseStateForUnknownIfFunc is a conditional function used in UseStateForUnknownIf -type UseStateForUnknownIfFunc func(context.Context, string, planmodifier.StringRequest, *UseStateForUnknownFuncResponse) +type UseStateForUnknownIfFunc func(context.Context, planmodifier.StringRequest, *UseStateForUnknownFuncResponse) type useStateForUnknownIf struct { - ifFunc UseStateForUnknownIfFunc - attributeName string - description string + ifFunc UseStateForUnknownIfFunc + description string } // UseStateForUnknownIf returns a plan modifier similar to UseStateForUnknown with a conditional -func UseStateForUnknownIf(f UseStateForUnknownIfFunc, attributeName, description string) planmodifier.String { +func UseStateForUnknownIf(f UseStateForUnknownIfFunc, description string) planmodifier.String { return useStateForUnknownIf{ - ifFunc: f, - attributeName: attributeName, - description: description, + ifFunc: f, + description: description, } } @@ -59,7 +58,7 @@ func (m useStateForUnknownIf) PlanModifyString(ctx context.Context, req planmodi // (https://github.com/hashicorp/terraform-plugin-framework/blob/44348af3923c82a93c64ae7dca906d9850ba956b/resource/schema/stringplanmodifier/use_state_for_unknown.go#L38) funcResponse := &UseStateForUnknownFuncResponse{} - m.ifFunc(ctx, m.attributeName, req, funcResponse) + m.ifFunc(ctx, req, funcResponse) resp.Diagnostics.Append(funcResponse.Diagnostics...) if resp.Diagnostics.HasError() { @@ -71,26 +70,50 @@ func (m useStateForUnknownIf) PlanModifyString(ctx context.Context, req planmodi } } -// StringChanged sets UseStateForUnkown to true if the attribute's planned value matches the current state -func StringChanged(ctx context.Context, attributeName string, request planmodifier.StringRequest, response *UseStateForUnknownFuncResponse) { // nolint:gocritic // function signature required by Terraform - dependencyPath := request.Path.ParentPath().AtName(attributeName) - - var attributePlan types.String - diags := request.Plan.GetAttribute(ctx, dependencyPath, &attributePlan) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return - } - - var attributeState types.String - diags = request.State.GetAttribute(ctx, dependencyPath, &attributeState) - response.Diagnostics.Append(diags...) - if response.Diagnostics.HasError() { - return +// StringUnchanged sets UseStateForUnkown to true if the attribute's planned value matches the current state +func StringUnchanged(attributePath path.Path) UseStateForUnknownIfFunc { // nolint:gocritic // function signature required by Terraform + return func(ctx context.Context, request planmodifier.StringRequest, response *UseStateForUnknownFuncResponse) { + var attributePlan types.String + diags := request.Plan.GetAttribute(ctx, attributePath, &attributePlan) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + var attributeState types.String + diags = request.State.GetAttribute(ctx, attributePath, &attributeState) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + if attributeState == attributePlan { + response.UseStateForUnknown = true + return + } } +} - if attributeState == attributePlan { - response.UseStateForUnknown = true - return +// Int64Unchanged sets UseStateForUnkown to true if the attribute's planned value matches the current state +func Int64Unchanged(attributePath path.Path) UseStateForUnknownIfFunc { // nolint:gocritic // function signature required by Terraform + return func(ctx context.Context, request planmodifier.StringRequest, response *UseStateForUnknownFuncResponse) { + var attributePlan types.Int64 + diags := request.Plan.GetAttribute(ctx, attributePath, &attributePlan) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + var attributeState types.Int64 + diags = request.State.GetAttribute(ctx, attributePath, &attributeState) + response.Diagnostics.Append(diags...) + if response.Diagnostics.HasError() { + return + } + + if attributeState == attributePlan { + response.UseStateForUnknown = true + return + } } } diff --git a/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if_test.go b/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if_test.go index 4a2c5a887..8e6813eee 100644 --- a/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if_test.go +++ b/stackit/internal/utils/planmodifiers/stringplanmodifier/use_state_for_unknown_if_test.go @@ -25,7 +25,7 @@ func TestUseStateForUnknownIf_PlanModifyString(t *testing.T) { stateValue: types.StringNull(), planValue: types.StringUnknown(), configValue: types.StringValue("some-config"), - ifFunc: func(_ context.Context, _ string, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { + ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { // This should not be reached because the state is null resp.UseStateForUnknown = true }, @@ -36,7 +36,7 @@ func TestUseStateForUnknownIf_PlanModifyString(t *testing.T) { stateValue: types.StringValue("old-state"), planValue: types.StringValue("new-plan"), configValue: types.StringValue("new-plan"), - ifFunc: func(_ context.Context, _ string, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { + ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { // This should not be reached because the plan is known resp.UseStateForUnknown = true }, @@ -47,7 +47,7 @@ func TestUseStateForUnknownIf_PlanModifyString(t *testing.T) { stateValue: types.StringValue("old-state"), planValue: types.StringUnknown(), configValue: types.StringUnknown(), - ifFunc: func(_ context.Context, _ string, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { + ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { // This should not be reached resp.UseStateForUnknown = true }, @@ -58,7 +58,7 @@ func TestUseStateForUnknownIf_PlanModifyString(t *testing.T) { stateValue: types.StringValue("old-state"), planValue: types.StringUnknown(), configValue: types.StringNull(), // Simulating computed only - ifFunc: func(_ context.Context, _ string, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { + ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { resp.UseStateForUnknown = false }, expectedPlanValue: types.StringUnknown(), @@ -68,7 +68,7 @@ func TestUseStateForUnknownIf_PlanModifyString(t *testing.T) { stateValue: types.StringValue("old-state"), planValue: types.StringUnknown(), configValue: types.StringNull(), - ifFunc: func(_ context.Context, _ string, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { + ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { resp.UseStateForUnknown = true }, expectedPlanValue: types.StringValue("old-state"), @@ -78,7 +78,7 @@ func TestUseStateForUnknownIf_PlanModifyString(t *testing.T) { stateValue: types.StringValue("old-state"), planValue: types.StringUnknown(), configValue: types.StringNull(), - ifFunc: func(_ context.Context, _ string, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { + ifFunc: func(_ context.Context, _ planmodifier.StringRequest, resp *UseStateForUnknownFuncResponse) { resp.Diagnostics.AddError("Test Error", "Something went wrong") }, expectedPlanValue: types.StringUnknown(), @@ -89,7 +89,7 @@ func TestUseStateForUnknownIf_PlanModifyString(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Initialize the modifier - modifier := UseStateForUnknownIf(tt.ifFunc, "", "test description") + modifier := UseStateForUnknownIf(tt.ifFunc, "test description") // Construct request req := planmodifier.StringRequest{ diff --git a/stackit/internal/utils/retry.go b/stackit/internal/utils/retry.go new file mode 100644 index 000000000..0057de824 --- /dev/null +++ b/stackit/internal/utils/retry.go @@ -0,0 +1,98 @@ +package utils + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" +) + +// RetryConfig defines the parameters for retrying an operation. +type RetryConfig struct { + Attempts int + + // Delay is the default wait duration between attempts. + // Used if Backoff is not provided. Default is 100ms + Delay time.Duration + + // Backoff allows customizing wait duration per attempt. + // "attempt" starts at 1 (representing the delay after the 1st failed try). + Backoff func(attempt int) time.Duration + + // RetryStatusCodes defines a list with HTTP Status codes on which the request should be retried + RetryStatusCodes []int +} + +// RetryRequest executes fn up to config.Attempts times until it succeeds or context is canceled. +func RetryRequest[T any](ctx context.Context, fn func() (*T, error), config RetryConfig) (*T, error) { + if fn == nil { + return nil, errors.New("retry function fn cannot be nil") + } + + if config.Attempts <= 0 { + config.Attempts = 1 + } + + if config.Delay <= 0 && config.Backoff == nil { + config.Delay = 100 * time.Millisecond + } + + var lastErr error + + for attempt := 1; attempt <= config.Attempts; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + + result, err := fn() + if err == nil { + return result, nil + } + + lastErr = err + + // Extract status code and verify if it's in the allowed list + if oapiErr, ok := errors.AsType[*oapierror.GenericOpenAPIError](err); ok { + if !slices.Contains(config.RetryStatusCodes, oapiErr.StatusCode) { + return nil, err + } + } + + // Don't wait after the last attempt + if attempt == config.Attempts { + break + } + + // Determine wait duration for the next attempt + waitDuration := config.Delay + if config.Backoff != nil { + waitDuration = config.Backoff(attempt) + } + + // Wait for delay duration or abort if context is canceled + timer := time.NewTimer(waitDuration) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + } + + return nil, fmt.Errorf("retry limit reached (%d attempts): %w", config.Attempts, lastErr) +} + +func RetryRequestWithoutResponse(ctx context.Context, fn func() error, config RetryConfig) error { + if fn == nil { + return errors.New("retry function fn cannot be nil") + } + wrapper := func() (*struct{}, error) { + err := fn() + return &struct{}{}, err + } + _, err := RetryRequest(ctx, wrapper, config) + return err +} diff --git a/stackit/internal/utils/retry_test.go b/stackit/internal/utils/retry_test.go new file mode 100644 index 000000000..691bedea3 --- /dev/null +++ b/stackit/internal/utils/retry_test.go @@ -0,0 +1,292 @@ +package utils + +import ( + "context" + "testing" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" +) + +func TestRetryRequest(t *testing.T) { + t.Parallel() + + makeOapiErr := func(statusCode int) error { + return &oapierror.GenericOpenAPIError{ + StatusCode: statusCode, + } + } + + tests := []struct { + name string + ctx func() (context.Context, context.CancelFunc) + returns []error + config RetryConfig + expectedCalls int + wantErr bool + wantResult string + }{ + { + name: "succeeds on first attempt", + returns: []error{ + nil, + }, + config: RetryConfig{ + Attempts: 3, + }, + expectedCalls: 1, + wantErr: false, + wantResult: "success", + }, + { + name: "succeeds after retrying matched status code", + returns: []error{ + makeOapiErr(429), + makeOapiErr(429), + nil, + }, + config: RetryConfig{ + Attempts: 3, + Delay: 1 * time.Millisecond, + RetryStatusCodes: []int{429}, + }, + expectedCalls: 3, + wantErr: false, + wantResult: "success", + }, + { + name: "fails immediately on non-matching status code", + returns: []error{ + makeOapiErr(400), + }, + config: RetryConfig{ + Attempts: 5, + RetryStatusCodes: []int{429, 500}, + }, + expectedCalls: 1, + wantErr: true, + }, + { + name: "fails after exceeding max attempts", + returns: []error{ + makeOapiErr(500), + makeOapiErr(500), + makeOapiErr(500), + }, + config: RetryConfig{ + Attempts: 3, + Delay: 1 * time.Millisecond, + RetryStatusCodes: []int{500}, + }, + expectedCalls: 3, + wantErr: true, + }, + { + name: "aborts when context is canceled mid-retry", + ctx: func() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 10*time.Millisecond) + }, + returns: []error{ + makeOapiErr(503), + makeOapiErr(503), + }, + config: RetryConfig{ + Attempts: 5, + Delay: 100 * time.Millisecond, + RetryStatusCodes: []int{503}, + }, + expectedCalls: 1, + wantErr: true, + }, + { + name: "fails if fn is nil", + returns: nil, + config: RetryConfig{Attempts: 3}, + expectedCalls: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + if tt.ctx != nil { + var cancel context.CancelFunc + ctx, cancel = tt.ctx() + defer cancel() + } + + calls := 0 + var fn func() (*string, error) + + if tt.returns != nil { + fn = func() (*string, error) { + if calls >= len(tt.returns) { + t.Fatalf("fn called more times (%d) than mocked return values (%d)", calls+1, len(tt.returns)) + } + err := tt.returns[calls] + calls++ + if err != nil { + return nil, err + } + return new("success"), nil + } + } + + res, err := RetryRequest(ctx, fn, tt.config) + + if (err != nil) != tt.wantErr { + t.Errorf("RetryRequest() error = %v, wantErr %v", err, tt.wantErr) + } + if calls != tt.expectedCalls { + t.Errorf("RetryRequest() fn called %d times, expected %d", calls, tt.expectedCalls) + } + if !tt.wantErr && (res == nil || *res != tt.wantResult) { + t.Errorf("RetryRequest() result = %v, want %v", res, tt.wantResult) + } + }) + } +} + +func TestRetryRequestWithoutResponse(t *testing.T) { + t.Parallel() + + // Helper to build OpenAPI errors for tests + makeOapiErr := func(statusCode int) error { + return &oapierror.GenericOpenAPIError{ + StatusCode: statusCode, + } + } + + tests := []struct { + name string + ctx func() (context.Context, context.CancelFunc) + returns []error // Errors to return sequentially on each invocation + config RetryConfig + expectedCalls int + wantErr bool + }{ + { + name: "succeeds on first attempt", + returns: []error{ + nil, + }, + config: RetryConfig{ + Attempts: 3, + }, + expectedCalls: 1, + wantErr: false, + }, + { + name: "succeeds after retrying matched status code", + returns: []error{ + makeOapiErr(429), + makeOapiErr(429), + nil, // Succeeds on 3rd try + }, + config: RetryConfig{ + Attempts: 3, + Delay: 1 * time.Millisecond, + RetryStatusCodes: []int{429}, + }, + expectedCalls: 3, + wantErr: false, + }, + { + name: "fails immediately on non-matching status code", + returns: []error{ + makeOapiErr(400), // Bad Request (not in retry list) + }, + config: RetryConfig{ + Attempts: 5, + RetryStatusCodes: []int{429, 500}, + }, + expectedCalls: 1, // Stops after 1st try + wantErr: true, + }, + { + name: "fails after exceeding max attempts", + returns: []error{ + makeOapiErr(500), + makeOapiErr(500), + makeOapiErr(500), + }, + config: RetryConfig{ + Attempts: 3, + Delay: 1 * time.Millisecond, + RetryStatusCodes: []int{500}, + }, + expectedCalls: 3, + wantErr: true, + }, + { + name: "aborts when context is canceled mid-retry", + ctx: func() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + return ctx, cancel + }, + returns: []error{ + makeOapiErr(503), + makeOapiErr(503), + }, + config: RetryConfig{ + Attempts: 5, + Delay: 100 * time.Millisecond, // Delay is longer than context timeout + RetryStatusCodes: []int{503}, + }, + expectedCalls: 1, // Aborts during the wait after 1st attempt + wantErr: true, + }, + { + name: "fails if fn is nil", + returns: nil, + config: RetryConfig{ + Attempts: 3, + }, + expectedCalls: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // 1. Context Setup + ctx := context.Background() + if tt.ctx != nil { + var cancel context.CancelFunc + ctx, cancel = tt.ctx() + defer cancel() + } + + // 2. Mock execution state local to this subtest + calls := 0 + var fn func() error + + if tt.returns != nil { + fn = func() error { + if calls >= len(tt.returns) { + t.Fatalf("fn called more times (%d) than mocked return values (%d)", calls+1, len(tt.returns)) + } + err := tt.returns[calls] + calls++ + return err + } + } + + // 3. Execution + err := RetryRequestWithoutResponse(ctx, fn, tt.config) + + // 4. Assertions + if (err != nil) != tt.wantErr { + t.Errorf("RetryRequestWithoutResponse() error = %v, wantErr %v", err, tt.wantErr) + } + if calls != tt.expectedCalls { + t.Errorf("fn() called %d times, expected %d", calls, tt.expectedCalls) + } + }) + } +}