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

fix: include recent api changes
Reviewed-on: #65
Co-authored-by: Marcel S. Henselin <marcel.henselin@stackit.cloud>
Co-committed-by: Marcel S. Henselin <marcel.henselin@stackit.cloud>
This commit is contained in:
Marcel_Henselin 2026-02-16 09:04:16 +00:00 committed by Marcel_Henselin
parent 452f73877f
commit 43223f5d1f
Signed by: tf-provider.git.onstackit.cloud
GPG key ID: 6D7E8A1ED8955A9C
13 changed files with 202 additions and 63 deletions

2
.gitignore vendored
View file

@ -46,3 +46,5 @@ dist
pkg_gen pkg_gen
/release/ /release/
.env
**/.env

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. - `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. - `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. - `name` (String) The name of the database.
- `owner` (String) The owner of the database. - `owner` (String) The owner of the database.
- `tf_original_api_id` (Number) The id 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 ### Required
- `cpu` (Number) The cpu count of the instance. - `cpu` (Number) The cpu count of the instance.
- `node_type` (String) defines the nodeType it can be either single or replica - `node_type` (String) defines the nodeType it can be either single or HA
- `project_id` (String) The cpu count of the instance. - `project_id` (String) The project ID of the flavor.
- `ram` (Number) The memory of the instance in Gibibyte. - `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. - `storage_class` (String) The memory of the instance in Gibibyte.
### Read-Only ### Read-Only
- `description` (String) The flavor description. - `description` (String) The flavor description.
- `flavor_id` (String) The flavor id of the instance flavor. - `flavor_id` (String) The id of the instance flavor.
- `id` (String) The terraform 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. - `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. - `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> <a id="nestedatt--storage_classes"></a>
### Nested Schema for `storage_classes` ### Nested Schema for `storage_classes`

View file

@ -34,7 +34,6 @@ data "stackitprivatepreview_sqlserverflexalpha_instance" "example" {
- `edition` (String) Edition of the MSSQL server instance - `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)) - `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. - `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. - `is_deletable` (Boolean) Whether the instance can be deleted or not.
- `name` (String) The name of the instance. - `name` (String) The name of the instance.
- `network` (Attributes) The access configuration of the instance (see [below for nested schema](#nestedatt--network)) - `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" page_title: "stackitprivatepreview_sqlserverflexalpha_user Data Source - stackitprivatepreview"
subcategory: "" subcategory: ""
description: |- description: |-
SQLServer Flex user data source schema. Must have a region specified in the provider configuration.
--- ---
# stackitprivatepreview_sqlserverflexalpha_user (Data Source) # stackitprivatepreview_sqlserverflexalpha_user (Data Source)
SQLServer Flex user data source schema. Must have a `region` specified in the provider configuration.
## Example Usage ## Example Usage
@ -25,20 +25,38 @@ data "stackitprivatepreview_sqlserverflexalpha_user" "example" {
### Required ### Required
- `instance_id` (String) ID of the SQLServer Flex instance. - `instance_id` (String) The ID of the instance.
- `project_id` (String) STACKIT project ID to which the instance is associated. - `project_id` (String) The STACKIT project ID.
- `user_id` (Number) User ID. - `region` (String) The region which should be addressed
### Optional ### 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 ### Read-Only
- `default_database` (String) - `pagination` (Attributes) (see [below for nested schema](#nestedatt--pagination))
- `host` (String) - `users` (Attributes List) List of all users inside an instance (see [below for nested schema](#nestedatt--users))
- `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`region`,`instance_id`,`user_id`".
- `port` (Number) <a id="nestedatt--pagination"></a>
- `roles` (Set of String) Database access levels for the user. ### Nested Schema for `pagination`
- `status` (String)
- `username` (String) Username of the SQLServer Flex instance. 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

@ -44,7 +44,6 @@ import {
### Read-Only ### Read-Only
- `connection_string` (String) The connection string for the user to the instance.
- `id` (Number) The ID of the user. - `id` (Number) The ID of the user.
- `password` (String) The password for the user. - `password` (String) The password for the user.
- `status` (String) The current status of the user. - `status` (String) The current status of the user.

View file

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

View file

@ -5,6 +5,7 @@ import (
_ "embed" _ "embed"
"fmt" "fmt"
"math" "math"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -29,11 +30,12 @@ import (
var ( var (
// Ensure the implementation satisfies the expected interfaces. // Ensure the implementation satisfies the expected interfaces.
_ resource.Resource = &userResource{} _ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{} _ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{} _ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{} _ resource.ResourceWithModifyPlan = &userResource{}
_ resource.ResourceWithIdentity = &userResource{} _ resource.ResourceWithIdentity = &userResource{}
_ resource.ResourceWithValidateConfig = &userResource{}
// Error message constants // Error message constants
extractErrorSummary = "extracting failed" extractErrorSummary = "extracting failed"
@ -138,6 +140,39 @@ func (r *userResource) Schema(ctx context.Context, _ resource.SchemaRequest, res
resp.Schema = s 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. // Create creates the resource and sets the initial Terraform state.
func (r *userResource) Create( func (r *userResource) Create(
ctx context.Context, ctx context.Context,
@ -217,7 +252,7 @@ func (r *userResource) Create(
model.UserId = types.Int64Value(id) model.UserId = types.Int64Value(id)
model.Password = types.StringValue(userResp.GetPassword()) model.Password = types.StringValue(userResp.GetPassword())
model.Status = types.StringValue(userResp.GetStatus()) model.Status = types.StringValue(userResp.GetStatus())
model.ConnectionString = types.StringValue(userResp.GetConnectionString()) //model.ConnectionString = types.StringValue(userResp.GetConnectionString())
waitResp, err := postgresflexalphaWait.GetUserByIdWaitHandler( waitResp, err := postgresflexalphaWait.GetUserByIdWaitHandler(
ctx, ctx,
@ -712,5 +747,6 @@ func (r *userResource) expandRoles(ctx context.Context, rolesSet types.List, dia
} }
var roles []string var roles []string
diags.Append(rolesSet.ElementsAs(ctx, &roles, false)...) diags.Append(rolesSet.ElementsAs(ctx, &roles, false)...)
slices.Sort(roles)
return roles return roles
} }

View file

@ -14,11 +14,6 @@ import (
func UserResourceSchema(ctx context.Context) schema.Schema { func UserResourceSchema(ctx context.Context) schema.Schema {
return schema.Schema{ return schema.Schema{
Attributes: map[string]schema.Attribute{ Attributes: map[string]schema.Attribute{
"connection_string": schema.StringAttribute{
Computed: true,
Description: "The connection string for the user to the instance.",
MarkdownDescription: "The connection string for the user to the instance.",
},
"id": schema.Int64Attribute{ "id": schema.Int64Attribute{
Computed: true, Computed: true,
Description: "The ID of the user.", Description: "The ID of the user.",
@ -80,14 +75,13 @@ func UserResourceSchema(ctx context.Context) schema.Schema {
} }
type UserModel struct { type UserModel struct {
ConnectionString types.String `tfsdk:"connection_string"` Id types.Int64 `tfsdk:"id"`
Id types.Int64 `tfsdk:"id"` InstanceId types.String `tfsdk:"instance_id"`
InstanceId types.String `tfsdk:"instance_id"` Name types.String `tfsdk:"name"`
Name types.String `tfsdk:"name"` Password types.String `tfsdk:"password"`
Password types.String `tfsdk:"password"` ProjectId types.String `tfsdk:"project_id"`
ProjectId types.String `tfsdk:"project_id"` Region types.String `tfsdk:"region"`
Region types.String `tfsdk:"region"` Roles types.List `tfsdk:"roles"`
Roles types.List `tfsdk:"roles"` Status types.String `tfsdk:"status"`
Status types.String `tfsdk:"status"` UserId types.Int64 `tfsdk:"user_id"`
UserId types.Int64 `tfsdk:"user_id"`
} }

View file

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

View file

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -30,11 +31,12 @@ import (
) )
var ( var (
_ resource.Resource = &userResource{} _ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{} _ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{} _ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{} _ resource.ResourceWithModifyPlan = &userResource{}
_ resource.ResourceWithIdentity = &userResource{} _ resource.ResourceWithIdentity = &userResource{}
_ resource.ResourceWithValidateConfig = &userResource{}
) )
func NewUserResource() resource.Resource { 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. // Create creates the resource and sets the initial Terraform state.
func (r *userResource) Create( func (r *userResource) Create(
ctx context.Context, ctx context.Context,
@ -186,6 +221,8 @@ func (r *userResource) Create(
if resp.Diagnostics.HasError() { if resp.Diagnostics.HasError() {
return return
} }
slices.Sort(roles)
} }
// Generate API request body from model // Generate API request body from model

View file

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

View file

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -30,11 +31,12 @@ import (
) )
var ( var (
_ resource.Resource = &userResource{} _ resource.Resource = &userResource{}
_ resource.ResourceWithConfigure = &userResource{} _ resource.ResourceWithConfigure = &userResource{}
_ resource.ResourceWithImportState = &userResource{} _ resource.ResourceWithImportState = &userResource{}
_ resource.ResourceWithModifyPlan = &userResource{} _ resource.ResourceWithModifyPlan = &userResource{}
_ resource.ResourceWithIdentity = &userResource{} _ resource.ResourceWithIdentity = &userResource{}
_ resource.ResourceWithValidateConfig = &userResource{}
) )
func NewUserResource() resource.Resource { 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. // Create creates the resource and sets the initial Terraform state.
func (r *userResource) Create( func (r *userResource) Create(
ctx context.Context, ctx context.Context,
@ -186,6 +221,7 @@ func (r *userResource) Create(
if resp.Diagnostics.HasError() { if resp.Diagnostics.HasError() {
return return
} }
slices.Sort(roles)
} }
// Generate API request body from model // Generate API request body from model
@ -379,7 +415,7 @@ func (r *userResource) Update(
resp *resource.UpdateResponse, resp *resource.UpdateResponse,
) { // nolint:gocritic // function signature required by Terraform ) { // nolint:gocritic // function signature required by Terraform
// Update shouldn't be called // 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. // Delete deletes the resource and removes the Terraform state on success.