Skip to content

Commit 8dc894c

Browse files
authored
Preserve order of project members even if API re-orders them (#484)
* Preserve order of project members even if API re-orders them * Adjust role field description * Fix backwards compatibility of deprecated owner_email field * Fix typo
1 parent 63b07c4 commit 8dc894c

5 files changed

Lines changed: 174 additions & 38 deletions

File tree

docs/data-sources/resourcemanager_project.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,5 @@ data "stackit_resourcemanager_project" "example" {
4343

4444
Read-Only:
4545

46-
- `role` (String) The role of the member in the project. At least one user must have the `owner` role. Legacy roles (`project.admin`, `project.auditor`, `project.member`, `project.owner`) are not supported.
46+
- `role` (String) The role of the member in the project. Legacy roles (`project.admin`, `project.auditor`, `project.member`, `project.owner`) are not supported.
4747
- `subject` (String) Unique identifier of the user, service account or client. This is usually the email address for users or service accounts, and the name in case of clients.

docs/resources/resourcemanager_project.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,5 @@ resource "stackit_resourcemanager_project" "example" {
5050

5151
Required:
5252

53-
- `role` (String) The role of the member in the project. Possible values include, but are not limited to: `owner`, `editor`, `reader`. At least one user must have the `owner` role. Legacy roles (`project.admin`, `project.auditor`, `project.member`, `project.owner`) are not supported.
53+
- `role` (String) The role of the member in the project. Possible values include, but are not limited to: `owner`, `editor`, `reader`. Legacy roles (`project.admin`, `project.auditor`, `project.member`, `project.owner`) are not supported.
5454
- `subject` (String) Unique identifier of the user, service account or client. This is usually the email address for users or service accounts, and the name in case of clients.

stackit/internal/services/resourcemanager/project/datasource.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest
112112
"owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.",
113113
"owner_email_deprecation_message": "The \"owner_email\" field has been deprecated in favor of the \"members\" field. Please use the \"members\" field to assign the owner role to a user, by setting the \"role\" field to `owner`.",
114114
"members": "The members assigned to the project. At least one subject needs to be a user, and not a client or service account.",
115-
"members.role": fmt.Sprintf("The role of the member in the project. At least one user must have the `owner` role. Legacy roles (%s) are not supported.", strings.Join(utils.QuoteValues(utils.LegacyProjectRoles), ", ")),
115+
"members.role": fmt.Sprintf("The role of the member in the project. Legacy roles (%s) are not supported.", strings.Join(utils.QuoteValues(utils.LegacyProjectRoles), ", ")),
116116
"members.subject": "Unique identifier of the user, service account or client. This is usually the email address for users or service accounts, and the name in case of clients.",
117117
}
118118

@@ -246,7 +246,7 @@ func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest
246246
return
247247
}
248248

249-
err = mapMembersFields(membersResp.Members, &model)
249+
err = mapMembersFields(ctx, membersResp.Members, &model)
250250
if err != nil {
251251
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API response: %v", err))
252252
return

stackit/internal/services/resourcemanager/project/resource.go

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, re
157157
"owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.",
158158
"owner_email_deprecation_message": "The \"owner_email\" field has been deprecated in favor of the \"members\" field. Please use the \"members\" field to assign the owner role to a user, by setting the \"role\" field to `owner`.",
159159
"members": "The members assigned to the project. At least one subject needs to be a user, and not a client or service account.",
160-
"members.role": fmt.Sprintf("The role of the member in the project. Possible values include, but are not limited to: `owner`, `editor`, `reader`. At least one user must have the `owner` role. Legacy roles (%s) are not supported.", strings.Join(utils.QuoteValues(utils.LegacyProjectRoles), ", ")),
160+
"members.role": fmt.Sprintf("The role of the member in the project. Possible values include, but are not limited to: `owner`, `editor`, `reader`. Legacy roles (%s) are not supported.", strings.Join(utils.QuoteValues(utils.LegacyProjectRoles), ", ")),
161161
"members.subject": "Unique identifier of the user, service account or client. This is usually the email address for users or service accounts, and the name in case of clients.",
162162
}
163163

@@ -316,7 +316,7 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest
316316
return
317317
}
318318

319-
err = mapMembersFields(membersResp.Members, &model)
319+
err = mapMembersFields(ctx, membersResp.Members, &model)
320320
if err != nil {
321321
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating project", fmt.Sprintf("Processing API payload: %v", err))
322322
return
@@ -364,7 +364,7 @@ func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, re
364364
return
365365
}
366366

367-
err = mapMembersFields(membersResp.Members, &model)
367+
err = mapMembersFields(ctx, membersResp.Members, &model)
368368
if err != nil {
369369
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API response: %v", err))
370370
return
@@ -428,7 +428,7 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest
428428
return
429429
}
430430

431-
err = mapMembersFields(members, &model)
431+
err = mapMembersFields(ctx, members, &model)
432432
if err != nil {
433433
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Processing API response: %v", err))
434434
return
@@ -561,7 +561,7 @@ func mapProjectFields(ctx context.Context, projectResp *resourcemanager.GetProje
561561
return nil
562562
}
563563

564-
func mapMembersFields(members *[]authorization.Member, model *Model) error {
564+
func mapMembersFields(ctx context.Context, members *[]authorization.Member, model *Model) error {
565565
if members == nil {
566566
model.Members = types.ListNull(types.ObjectType{AttrTypes: memberTypes})
567567
return nil
@@ -574,14 +574,42 @@ func mapMembersFields(members *[]authorization.Member, model *Model) error {
574574
return nil
575575
}
576576

577-
membersList := []attr.Value{}
578-
for i, m := range *members {
577+
modelMembers := []member{}
578+
if !(model.Members.IsNull() || model.Members.IsUnknown()) {
579+
diags := model.Members.ElementsAs(ctx, &modelMembers, false)
580+
if diags.HasError() {
581+
return fmt.Errorf("processing members: %w", core.DiagsToError(diags))
582+
}
583+
}
584+
modelMemberIds := make([]string, len(modelMembers))
585+
for i, m := range modelMembers {
586+
modelMemberIds[i] = memberId(authorization.Member{
587+
Role: m.Role.ValueStringPointer(),
588+
Subject: m.Subject.ValueStringPointer(),
589+
})
590+
}
591+
592+
apiMemberIds := []string{}
593+
for _, m := range *members {
579594
if utils.IsLegacyProjectRole(*m.Role) {
580595
continue
581596
}
597+
apiMemberIds = append(apiMemberIds, memberId(m))
598+
}
599+
600+
reconciledMembersIds := utils.ReconcileStringSlices(modelMemberIds, apiMemberIds)
601+
602+
membersList := []attr.Value{}
603+
for i, m := range reconciledMembersIds {
604+
role := roleFromId(m)
605+
subject := subjectFromId(m)
606+
if role == "" || subject == "" {
607+
return fmt.Errorf("reconcile list of members")
608+
}
609+
582610
membersMap := map[string]attr.Value{
583-
"subject": types.StringPointerValue(m.Subject),
584-
"role": types.StringPointerValue(m.Role),
611+
"subject": types.StringValue(subject),
612+
"role": types.StringValue(role),
585613
}
586614

587615
memberTF, diags := types.ObjectValue(memberTypes, membersMap)
@@ -609,7 +637,16 @@ func toMembersPayload(ctx context.Context, model *Model) (*[]authorization.Membe
609637
return nil, fmt.Errorf("nil model")
610638
}
611639
if model.Members.IsNull() || model.Members.IsUnknown() {
612-
return &[]authorization.Member{}, nil
640+
if model.OwnerEmail.IsNull() {
641+
return nil, fmt.Errorf("members and owner_email are both null or unknown")
642+
}
643+
644+
return &[]authorization.Member{
645+
{
646+
Subject: model.OwnerEmail.ValueStringPointer(),
647+
Role: sdkUtils.Ptr(projectOwnerRole),
648+
},
649+
}, nil
613650
}
614651

615652
membersModel := []member{}
@@ -618,19 +655,12 @@ func toMembersPayload(ctx context.Context, model *Model) (*[]authorization.Membe
618655
return nil, core.DiagsToError(diags)
619656
}
620657

621-
members := []authorization.Member{}
622658
// If the new "members" fields is set, it has precedence over the deprecated "owner_email" field
623-
if !model.Members.IsNull() && !model.Members.IsUnknown() {
624-
for _, m := range membersModel {
625-
members = append(members, authorization.Member{
626-
Role: m.Role.ValueStringPointer(),
627-
Subject: m.Subject.ValueStringPointer(),
628-
})
629-
}
630-
} else {
659+
members := []authorization.Member{}
660+
for _, m := range membersModel {
631661
members = append(members, authorization.Member{
632-
Subject: model.OwnerEmail.ValueStringPointer(),
633-
Role: sdkUtils.Ptr(projectOwnerRole),
662+
Role: m.Role.ValueStringPointer(),
663+
Subject: m.Subject.ValueStringPointer(),
634664
})
635665
}
636666

@@ -791,3 +821,21 @@ func updateMembers(ctx context.Context, projectId string, modelMembers *[]author
791821
func memberId(member authorization.Member) string {
792822
return fmt.Sprintf("%s,%s", *member.Subject, *member.Role)
793823
}
824+
825+
// Extract the role from the member ID representation
826+
func roleFromId(id string) string {
827+
parts := strings.Split(id, ",")
828+
if len(parts) != 2 {
829+
return ""
830+
}
831+
return parts[1]
832+
}
833+
834+
// Extract the subject from the member ID representation
835+
func subjectFromId(id string) string {
836+
parts := strings.Split(id, ",")
837+
if len(parts) != 2 {
838+
return ""
839+
}
840+
return parts[0]
841+
}

stackit/internal/services/resourcemanager/project/resource_test.go

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/gorilla/mux"
1313
"github.com/hashicorp/terraform-plugin-framework/attr"
1414
"github.com/hashicorp/terraform-plugin-framework/types"
15+
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
1516
"github.com/stackitcloud/stackit-sdk-go/core/config"
1617
"github.com/stackitcloud/stackit-sdk-go/core/utils"
1718
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
@@ -165,13 +166,15 @@ func TestMapProjectFields(t *testing.T) {
165166
func TestMapMembersFields(t *testing.T) {
166167
tests := []struct {
167168
description string
169+
configMembers basetypes.ListValue
168170
membersResp *[]authorization.Member
169171
expected Model
170172
expectedLabels *map[string]string
171173
isValid bool
172174
}{
173175
{
174176
"default_ok",
177+
types.ListNull(types.ObjectType{AttrTypes: memberTypes}),
175178
&[]authorization.Member{
176179
{
177180
Subject: utils.Ptr("owner_email"),
@@ -209,8 +212,64 @@ func TestMapMembersFields(t *testing.T) {
209212
nil,
210213
true,
211214
},
215+
{
216+
"default_ok (preserve model order)",
217+
types.ListValueMust(types.ObjectType{AttrTypes: memberTypes}, []attr.Value{
218+
types.ObjectValueMust(
219+
memberTypes,
220+
map[string]attr.Value{
221+
"subject": types.StringValue("reader_email"),
222+
"role": types.StringValue("reader"),
223+
},
224+
),
225+
types.ObjectValueMust(
226+
memberTypes,
227+
map[string]attr.Value{
228+
"subject": types.StringValue("owner_email"),
229+
"role": types.StringValue("owner"),
230+
},
231+
),
232+
}),
233+
&[]authorization.Member{
234+
{
235+
Subject: utils.Ptr("owner_email"),
236+
Role: utils.Ptr("owner"),
237+
},
238+
{
239+
Subject: utils.Ptr("reader_email"),
240+
Role: utils.Ptr("reader"),
241+
},
242+
},
243+
Model{
244+
Id: types.StringNull(),
245+
ProjectId: types.StringNull(),
246+
ContainerId: types.StringNull(),
247+
ContainerParentId: types.StringNull(),
248+
Name: types.StringNull(),
249+
Labels: types.MapNull(types.StringType),
250+
Members: types.ListValueMust(types.ObjectType{AttrTypes: memberTypes}, []attr.Value{
251+
types.ObjectValueMust(
252+
memberTypes,
253+
map[string]attr.Value{
254+
"subject": types.StringValue("reader_email"),
255+
"role": types.StringValue("reader"),
256+
},
257+
),
258+
types.ObjectValueMust(
259+
memberTypes,
260+
map[string]attr.Value{
261+
"subject": types.StringValue("owner_email"),
262+
"role": types.StringValue("owner"),
263+
},
264+
),
265+
}),
266+
},
267+
nil,
268+
true,
269+
},
212270
{
213271
"empty members",
272+
types.ListNull(types.ObjectType{AttrTypes: memberTypes}),
214273
&[]authorization.Member{},
215274
Model{
216275
Id: types.StringNull(),
@@ -226,6 +285,7 @@ func TestMapMembersFields(t *testing.T) {
226285
},
227286
{
228287
"nil members",
288+
types.ListNull(types.ObjectType{AttrTypes: memberTypes}),
229289
nil,
230290
Model{
231291
Id: types.StringNull(),
@@ -250,7 +310,10 @@ func TestMapMembersFields(t *testing.T) {
250310
Name: types.StringNull(),
251311
Labels: types.MapNull(types.StringType),
252312
}
253-
err := mapMembersFields(tt.membersResp, state)
313+
if !tt.configMembers.IsNull() {
314+
state.Members = tt.configMembers
315+
}
316+
err := mapMembersFields(context.Background(), tt.membersResp, state)
254317
if !tt.isValid && err == nil {
255318
t.Fatalf("Should have failed")
256319
}
@@ -275,18 +338,6 @@ func TestToCreatePayload(t *testing.T) {
275338
expected *resourcemanager.CreateProjectPayload
276339
isValid bool
277340
}{
278-
{
279-
"default_ok",
280-
&Model{},
281-
nil,
282-
&resourcemanager.CreateProjectPayload{
283-
ContainerParentId: nil,
284-
Labels: nil,
285-
Members: nil,
286-
Name: nil,
287-
},
288-
true,
289-
},
290341
{
291342
"mapping_with_conversions_single_member",
292343
&Model{
@@ -404,6 +455,43 @@ func TestToCreatePayload(t *testing.T) {
404455
},
405456
true,
406457
},
458+
{
459+
"deprecated owner_email field still works",
460+
&Model{
461+
ContainerParentId: types.StringValue("pid"),
462+
Name: types.StringValue("name"),
463+
OwnerEmail: types.StringValue("some_email_deprecated"),
464+
},
465+
&map[string]string{
466+
"label1": "1",
467+
"label2": "2",
468+
},
469+
&resourcemanager.CreateProjectPayload{
470+
ContainerParentId: utils.Ptr("pid"),
471+
Labels: &map[string]string{
472+
"label1": "1",
473+
"label2": "2",
474+
},
475+
Members: &[]resourcemanager.Member{
476+
{
477+
Subject: utils.Ptr("some_email_deprecated"),
478+
Role: utils.Ptr("owner"),
479+
},
480+
},
481+
Name: utils.Ptr("name"),
482+
},
483+
true,
484+
},
485+
{
486+
"no members or owner_email fails",
487+
&Model{
488+
ContainerParentId: types.StringValue("pid"),
489+
Name: types.StringValue("name"),
490+
},
491+
&map[string]string{},
492+
nil,
493+
false,
494+
},
407495
{
408496
"nil_model",
409497
nil,

0 commit comments

Comments
 (0)