fix: #63 sort user roles to prevent state change #65

Merged
marcel.henselin merged 4 commits from fix/#63_sort_roles into alpha 2026-02-16 09:04:17 +00:00
10 changed files with 190 additions and 46 deletions
Showing only changes of commit 01d4ca8c5e - Show all commits

View file

@ -26,7 +26,7 @@ description: |-
- `collation_name` (String) The collation of the database. This database collation should match the *collation_name* of one of the collations given by the **Get database collation list** endpoint.
- `compatibility_level` (Number) CompatibilityLevel of the Database.
- `id` (String) Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_id`,`database_id`\".",
- `id` (String) The terraform internal identifier.
- `name` (String) The name of the database.
- `owner` (String) The owner of the database.
- `tf_original_api_id` (Number) The id of the database.

View file

@ -29,20 +29,20 @@ data "stackitprivatepreview_sqlserverflexalpha_flavor" "flavor" {
### Required
- `cpu` (Number) The cpu count of the instance.
- `node_type` (String) defines the nodeType it can be either single or replica
- `project_id` (String) The cpu count of the instance.
- `node_type` (String) defines the nodeType it can be either single or HA
- `project_id` (String) The project ID of the flavor.
- `ram` (Number) The memory of the instance in Gibibyte.
- `region` (String) The flavor description.
- `region` (String) The region of the flavor.
- `storage_class` (String) The memory of the instance in Gibibyte.
### Read-Only
- `description` (String) The flavor description.
- `flavor_id` (String) The flavor id of the instance flavor.
- `id` (String) The terraform id of the instance flavor.
- `flavor_id` (String) The id of the instance flavor.
- `id` (String) The id of the instance flavor.
- `max_gb` (Number) maximum storage which can be ordered for the flavor in Gigabyte.
- `min_gb` (Number) minimum storage which is required to order in Gigabyte.
- `storage_classes` (Attributes List) (see [below for nested schema](#nestedatt--storage_classes))
- `storage_classes` (Attributes List) maximum storage which can be ordered for the flavor in Gigabyte. (see [below for nested schema](#nestedatt--storage_classes))
<a id="nestedatt--storage_classes"></a>
### Nested Schema for `storage_classes`

View file

@ -34,7 +34,6 @@ data "stackitprivatepreview_sqlserverflexalpha_instance" "example" {
- `edition` (String) Edition of the MSSQL server instance
- `encryption` (Attributes) this defines which key to use for storage encryption (see [below for nested schema](#nestedatt--encryption))
- `flavor_id` (String) The id of the instance flavor.
- `id` (String) Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_id`\".
- `is_deletable` (Boolean) Whether the instance can be deleted or not.
- `name` (String) The name of the instance.
- `network` (Attributes) The access configuration of the instance (see [below for nested schema](#nestedatt--network))

View file

@ -3,12 +3,12 @@
page_title: "stackitprivatepreview_sqlserverflexalpha_user Data Source - stackitprivatepreview"
subcategory: ""
description: |-
SQLServer Flex user data source schema. Must have a region specified in the provider configuration.
---
# stackitprivatepreview_sqlserverflexalpha_user (Data Source)
SQLServer Flex user data source schema. Must have a `region` specified in the provider configuration.
## Example Usage
@ -25,20 +25,38 @@ data "stackitprivatepreview_sqlserverflexalpha_user" "example" {
### Required
- `instance_id` (String) ID of the SQLServer Flex instance.
- `project_id` (String) STACKIT project ID to which the instance is associated.
- `user_id` (Number) User ID.
- `instance_id` (String) The ID of the instance.
- `project_id` (String) The STACKIT project ID.
- `region` (String) The region which should be addressed
### Optional
- `region` (String) The resource region. If not defined, the provider region is used.
- `page` (Number) Number of the page of items list to be returned.
- `size` (Number) Number of items to be returned on each page.
- `sort` (String) Sorting of the users to be returned on each page.
### Read-Only
- `default_database` (String)
- `host` (String)
- `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`region`,`instance_id`,`user_id`".
- `port` (Number)
- `roles` (Set of String) Database access levels for the user.
- `status` (String)
- `username` (String) Username of the SQLServer Flex instance.
- `pagination` (Attributes) (see [below for nested schema](#nestedatt--pagination))
- `users` (Attributes List) List of all users inside an instance (see [below for nested schema](#nestedatt--users))
<a id="nestedatt--pagination"></a>
### Nested Schema for `pagination`
Read-Only:
- `page` (Number)
- `size` (Number)
- `sort` (String)
- `total_pages` (Number)
- `total_rows` (Number)
<a id="nestedatt--users"></a>
### Nested Schema for `users`
Read-Only:
- `status` (String) The current status of the user.
- `tf_original_api_id` (Number) The ID of the user.
- `username` (String) The name of the user.

View file

@ -65,15 +65,15 @@ resource "stackitprivatepreview_postgresflexalpha_instance" "msh-sna-pe-example2
resource "stackitprivatepreview_postgresflexalpha_user" "ptlsdbadminuser" {
project_id = var.project_id
instance_id = stackitprivatepreview_postgresflexalpha_instance.msh-sna-pe-example.instance_id
username = var.db_admin_username
roles = ["createdb", "login"]
name = var.db_admin_username
roles = ["createdb", "login", "login"]
# roles = ["createdb", "login", "createrole"]
}
resource "stackitprivatepreview_postgresflexalpha_user" "ptlsdbadminuser2" {
project_id = var.project_id
instance_id = stackitprivatepreview_postgresflexalpha_instance.msh-sna-pe-example2.instance_id
username = var.db_admin_username
name = var.db_admin_username
roles = ["createdb", "login"]
# roles = ["createdb", "login", "createrole"]
}
@ -81,7 +81,7 @@ resource "stackitprivatepreview_postgresflexalpha_user" "ptlsdbadminuser2" {
resource "stackitprivatepreview_postgresflexalpha_user" "ptlsdbuser" {
project_id = var.project_id
instance_id = stackitprivatepreview_postgresflexalpha_instance.msh-sna-pe-example.instance_id
username = var.db_username
name = var.db_name
roles = ["login"]
# roles = ["createdb", "login", "createrole"]
}

View file

@ -5,6 +5,7 @@ import (
_ "embed"
"fmt"
"math"
"slices"
"strconv"
"strings"
"time"
@ -29,11 +30,12 @@ import (
var (
// Ensure the implementation satisfies the expected interfaces.
_ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{}
_ resource.ResourceWithIdentity = &userResource{}
_ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{}
_ resource.ResourceWithIdentity = &userResource{}
_ resource.ResourceWithValidateConfig = &userResource{}
// Error message constants
extractErrorSummary = "extracting failed"
@ -138,6 +140,39 @@ func (r *userResource) Schema(ctx context.Context, _ resource.SchemaRequest, res
resp.Schema = s
}
func (r *userResource) ValidateConfig(
ctx context.Context,
req resource.ValidateConfigRequest,
resp *resource.ValidateConfigResponse,
) {
var data resourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
var roles []string
diags := data.Roles.ElementsAs(ctx, &roles, false)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}
var resRoles []string
for _, role := range roles {
if slices.Contains(resRoles, role) {
resp.Diagnostics.AddAttributeError(
path.Root("roles"),
"Attribute Configuration Error",
"defined roles MUST NOT contain duplicates",
)
return
}
resRoles = append(resRoles, role)
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *userResource) Create(
ctx context.Context,
@ -712,5 +747,6 @@ func (r *userResource) expandRoles(ctx context.Context, rolesSet types.List, dia
}
var roles []string
diags.Append(rolesSet.ElementsAs(ctx, &roles, false)...)
slices.Sort(roles)
return roles
}

View file

@ -2,6 +2,7 @@ package sqlserverflexalpha
import (
"fmt"
"slices"
"strconv"
"github.com/hashicorp/terraform-plugin-framework/attr"
@ -44,8 +45,11 @@ func mapDataSourceFields(userResp *sqlserverflexalpha.GetUserResponse, model *da
if user.Roles == nil {
model.Roles = types.List(types.SetNull(types.StringType))
} else {
resRoles := *user.Roles
slices.Sort(resRoles)
var roles []attr.Value
for _, role := range *user.Roles {
for _, role := range resRoles {
roles = append(roles, types.StringValue(string(role)))
}
rolesSet, diags := types.SetValue(types.StringType, roles)
@ -92,8 +96,11 @@ func mapFields(userResp *sqlserverflexalpha.GetUserResponse, model *resourceMode
// Map roles
if user.Roles != nil {
resRoles := *user.Roles
slices.Sort(resRoles)
var roles []attr.Value
for _, role := range *user.Roles {
for _, role := range resRoles {
roles = append(roles, types.StringValue(string(role)))
}
rolesSet, diags := types.SetValue(types.StringType, roles)
@ -139,8 +146,11 @@ func mapFieldsCreate(userResp *sqlserverflexalpha.CreateUserResponse, model *res
model.Password = types.StringValue(*user.Password)
if user.Roles != nil {
resRoles := *user.Roles
slices.Sort(resRoles)
var roles []attr.Value
for _, role := range *user.Roles {
for _, role := range resRoles {
roles = append(roles, types.StringValue(string(role)))
}
rolesSet, diags := types.SetValue(types.StringType, roles)

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
@ -30,11 +31,12 @@ import (
)
var (
_ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{}
_ resource.ResourceWithIdentity = &userResource{}
_ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{}
_ resource.ResourceWithIdentity = &userResource{}
_ resource.ResourceWithValidateConfig = &userResource{}
)
func NewUserResource() resource.Resource {
@ -156,6 +158,39 @@ func (r *userResource) IdentitySchema(
}
}
func (r *userResource) ValidateConfig(
ctx context.Context,
req resource.ValidateConfigRequest,
resp *resource.ValidateConfigResponse,
) {
var data resourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
var roles []string
diags := data.Roles.ElementsAs(ctx, &roles, false)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}
var resRoles []string
for _, role := range roles {
if slices.Contains(resRoles, role) {
resp.Diagnostics.AddAttributeError(
path.Root("roles"),
"Attribute Configuration Error",
"defined roles MUST NOT contain duplicates",
)
return
}
resRoles = append(resRoles, role)
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *userResource) Create(
ctx context.Context,
@ -186,6 +221,8 @@ func (r *userResource) Create(
if resp.Diagnostics.HasError() {
return
}
slices.Sort(roles)
}
// Generate API request body from model

View file

@ -2,6 +2,7 @@ package sqlserverflexbeta
import (
"fmt"
"slices"
"strconv"
"github.com/hashicorp/terraform-plugin-framework/attr"
@ -92,10 +93,14 @@ func mapFields(userResp *sqlserverflexbeta.GetUserResponse, model *resourceModel
// Map roles
if user.Roles != nil {
resRoles := *user.Roles
slices.Sort(resRoles)
var roles []attr.Value
for _, role := range *user.Roles {
for _, role := range resRoles {
roles = append(roles, types.StringValue(string(role)))
}
rolesSet, diags := types.SetValue(types.StringType, roles)
if diags.HasError() {
return fmt.Errorf("failed to map roles: %w", core.DiagsToError(diags))
@ -139,8 +144,11 @@ func mapFieldsCreate(userResp *sqlserverflexbeta.CreateUserResponse, model *reso
model.Password = types.StringValue(*user.Password)
if user.Roles != nil {
resRoles := *user.Roles
slices.Sort(resRoles)
var roles []attr.Value
for _, role := range *user.Roles {
for _, role := range resRoles {
roles = append(roles, types.StringValue(string(role)))
}
rolesSet, diags := types.SetValue(types.StringType, roles)

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
@ -30,11 +31,12 @@ import (
)
var (
_ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{}
_ resource.ResourceWithIdentity = &userResource{}
_ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{}
_ resource.ResourceWithIdentity = &userResource{}
_ resource.ResourceWithValidateConfig = &userResource{}
)
func NewUserResource() resource.Resource {
@ -156,6 +158,39 @@ func (r *userResource) IdentitySchema(
}
}
func (r *userResource) ValidateConfig(
ctx context.Context,
req resource.ValidateConfigRequest,
resp *resource.ValidateConfigResponse,
) {
var data resourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
var roles []string
diags := data.Roles.ElementsAs(ctx, &roles, false)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}
var resRoles []string
for _, role := range roles {
if slices.Contains(resRoles, role) {
resp.Diagnostics.AddAttributeError(
path.Root("roles"),
"Attribute Configuration Error",
"defined roles MUST NOT contain duplicates",
)
return
}
resRoles = append(resRoles, role)
}
}
// Create creates the resource and sets the initial Terraform state.
func (r *userResource) Create(
ctx context.Context,
@ -186,6 +221,7 @@ func (r *userResource) Create(
if resp.Diagnostics.HasError() {
return
}
slices.Sort(roles)
}
// Generate API request body from model
@ -379,7 +415,7 @@ func (r *userResource) Update(
resp *resource.UpdateResponse,
) { // nolint:gocritic // function signature required by Terraform
// Update shouldn't be called
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", "User can't be updated")
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating user", "an SQL server user can not be updated, only created")
}
// Delete deletes the resource and removes the Terraform state on success.