Skip to content

Commit 03a8864

Browse files
committed
feat(iam): support scaleway_iam_group_membership batch update
* use SetGroupMembers * implement terraform update * use expo retry with SetGroupMembers as the API sometimes needs a bit of time to converge
1 parent 77939e4 commit 03a8864

File tree

5 files changed

+5141
-587
lines changed

5 files changed

+5141
-587
lines changed

internal/services/iam/group_membership.go

Lines changed: 140 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,74 +3,85 @@ package iam
33
import (
44
"context"
55
"fmt"
6+
"slices"
67
"strings"
8+
"time"
79

810
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
911
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1012
iam "github.com/scaleway/scaleway-sdk-go/api/iam/v1alpha1"
1113
"github.com/scaleway/scaleway-sdk-go/scw"
1214
"github.com/scaleway/terraform-provider-scaleway/v2/internal/httperrors"
15+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/transport"
1316
"github.com/scaleway/terraform-provider-scaleway/v2/internal/types"
1417
)
1518

19+
type EntityKind string
20+
21+
const (
22+
EntityKindUser EntityKind = "user"
23+
EntityKindApplication EntityKind = "application"
24+
)
25+
1626
func ResourceGroupMembership() *schema.Resource {
1727
return &schema.Resource{
1828
CreateContext: resourceIamGroupMembershipCreate,
1929
ReadContext: resourceIamGroupMembershipRead,
30+
UpdateContext: resourceIamGroupMembershipUpdate,
2031
DeleteContext: resourceIamGroupMembershipDelete,
2132
Importer: &schema.ResourceImporter{
2233
StateContext: schema.ImportStatePassthroughContext,
2334
},
2435
SchemaVersion: 0,
2536
Schema: map[string]*schema.Schema{
26-
"user_id": {
27-
Type: schema.TypeString,
28-
Optional: true,
29-
Description: "The ID of the user",
30-
ExactlyOneOf: []string{"application_id"},
31-
ForceNew: true,
32-
},
33-
"application_id": {
34-
Type: schema.TypeString,
35-
Optional: true,
36-
Description: "The ID of the user",
37-
ExactlyOneOf: []string{"user_id"},
38-
ForceNew: true,
39-
},
4037
"group_id": {
4138
Type: schema.TypeString,
4239
Required: true,
43-
Description: "The ID of the group to add the user to",
40+
Description: "The ID of the group to add the users or applications to",
4441
ForceNew: true,
4542
},
43+
"user_ids": {
44+
Type: schema.TypeList,
45+
Elem: &schema.Schema{Type: schema.TypeString},
46+
Optional: true,
47+
Description: "The IDs of the users to add to the group",
48+
AtLeastOneOf: []string{"application_ids"},
49+
},
50+
"application_ids": {
51+
Type: schema.TypeList,
52+
Elem: &schema.Schema{Type: schema.TypeString},
53+
Optional: true,
54+
Description: "The IDs of the applications to add to the group",
55+
AtLeastOneOf: []string{"user_ids"},
56+
},
4657
},
4758
}
4859
}
4960

5061
func resourceIamGroupMembershipCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
5162
api := NewAPI(m)
5263

53-
userID := types.ExpandStringPtr(d.Get("user_id"))
54-
applicationID := types.ExpandStringPtr(d.Get("application_id"))
64+
userIDs := types.ExpandStrings(d.Get("user_ids"))
65+
applicationIDs := types.ExpandStrings(d.Get("application_ids"))
5566

56-
group, err := api.AddGroupMember(&iam.AddGroupMemberRequest{
57-
GroupID: d.Get("group_id").(string),
58-
UserID: userID,
59-
ApplicationID: applicationID,
60-
}, scw.WithContext(ctx))
67+
group, err := MakeSetGroupMembershipRequest(ctx, api, &iam.SetGroupMembersRequest{
68+
GroupID: d.Get("group_id").(string),
69+
UserIDs: userIDs,
70+
ApplicationIDs: applicationIDs,
71+
})
6172
if err != nil {
6273
return diag.FromErr(err)
6374
}
6475

65-
d.SetId(GroupMembershipID(group.ID, userID, applicationID))
76+
d.SetId(SetGroupMembershipResourceID(group.ID, userIDs, applicationIDs))
6677

6778
return resourceIamGroupMembershipRead(ctx, d, m)
6879
}
6980

7081
func resourceIamGroupMembershipRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
7182
api := NewAPI(m)
7283

73-
groupID, userID, applicationID, err := ExpandGroupMembershipID(d.Id())
84+
groupID, entityIDs, err := ExpandGroupMembershipResourceID(d.Id())
7485
if err != nil {
7586
return diag.FromErr(err)
7687
}
@@ -88,58 +99,69 @@ func resourceIamGroupMembershipRead(ctx context.Context, d *schema.ResourceData,
8899
return diag.FromErr(err)
89100
}
90101

91-
foundInGroup := false
92-
93-
if userID != "" {
94-
for _, groupUserID := range group.UserIDs {
95-
if groupUserID == userID {
96-
foundInGroup = true
97-
98-
break
99-
}
100-
}
101-
} else if applicationID != "" {
102-
for _, groupApplicationID := range group.ApplicationIDs {
103-
if groupApplicationID == applicationID {
104-
foundInGroup = true
105-
106-
break
107-
}
102+
for _, userID := range entityIDs[EntityKindUser] {
103+
if !slices.Contains(group.UserIDs, userID) {
104+
return diag.FromErr(fmt.Errorf("user %s not found in group %s", userID, groupID))
108105
}
109106
}
110107

111-
if !foundInGroup {
112-
d.SetId("")
113-
114-
return nil
108+
for _, applicationID := range entityIDs[EntityKindApplication] {
109+
if !slices.Contains(group.ApplicationIDs, applicationID) {
110+
return diag.FromErr(fmt.Errorf("application %s not found in group %s", applicationID, groupID))
111+
}
115112
}
116113

117114
_ = d.Set("group_id", groupID)
118-
_ = d.Set("user_id", userID)
119-
_ = d.Set("application_id", applicationID)
115+
_ = d.Set("user_ids", entityIDs[EntityKindUser])
116+
_ = d.Set("application_ids", entityIDs[EntityKindApplication])
120117

121118
return nil
122119
}
123120

124-
func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
121+
func resourceIamGroupMembershipUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
125122
api := NewAPI(m)
126123

127-
groupID, userID, applicationID, err := ExpandGroupMembershipID(d.Id())
124+
groupID, _, err := ExpandGroupMembershipResourceID(d.Id())
128125
if err != nil {
129126
return diag.FromErr(err)
130127
}
131128

132-
req := &iam.RemoveGroupMemberRequest{
133-
GroupID: groupID,
129+
userIDs := types.ExpandStrings(d.Get("user_ids"))
130+
applicationIDs := types.ExpandStrings(d.Get("application_ids"))
131+
132+
request := &iam.SetGroupMembersRequest{
133+
GroupID: groupID,
134+
UserIDs: userIDs,
135+
ApplicationIDs: applicationIDs,
134136
}
135137

136-
if userID != "" {
137-
req.UserID = &userID
138-
} else if applicationID != "" {
139-
req.ApplicationID = &applicationID
138+
if d.HasChanges("user_ids", "application_ids") {
139+
group, err := MakeSetGroupMembershipRequest(ctx, api, request)
140+
if err != nil {
141+
return diag.FromErr(err)
142+
}
143+
144+
if group.ID != groupID {
145+
return diag.FromErr(fmt.Errorf("group id changed from %s to %s", groupID, group.ID))
146+
}
147+
148+
d.SetId(SetGroupMembershipResourceID(groupID, userIDs, applicationIDs))
149+
}
150+
151+
return resourceIamGroupMembershipRead(ctx, d, m)
152+
}
153+
154+
func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
155+
api := NewAPI(m)
156+
157+
groupID, _, err := ExpandGroupMembershipResourceID(d.Id())
158+
if err != nil {
159+
return diag.FromErr(err)
140160
}
141161

142-
_, err = api.RemoveGroupMember(req, scw.WithContext(ctx))
162+
_, err = MakeSetGroupMembershipRequest(ctx, api, &iam.SetGroupMembersRequest{
163+
GroupID: groupID,
164+
})
143165
if err != nil {
144166
if httperrors.Is404(err) {
145167
d.SetId("")
@@ -153,28 +175,78 @@ func resourceIamGroupMembershipDelete(ctx context.Context, d *schema.ResourceDat
153175
return nil
154176
}
155177

156-
func GroupMembershipID(groupID string, userID *string, applicationID *string) string {
157-
if userID != nil {
158-
return fmt.Sprintf("%s/user/%s", groupID, *userID)
178+
// Build a parsable state with the following format:
179+
// groupID/user:userID,application:applicationID
180+
func SetGroupMembershipResourceID(groupID string, userIDs []string, applicationIDs []string) (resourceID string) {
181+
entityIDs := make([]string, 0)
182+
183+
for _, userID := range userIDs {
184+
entityIDs = append(entityIDs, fmt.Sprintf("%s:%s", EntityKindUser, userID))
185+
}
186+
187+
for _, applicationID := range applicationIDs {
188+
entityIDs = append(entityIDs, fmt.Sprintf("%s:%s", EntityKindApplication, applicationID))
159189
}
160190

161-
return fmt.Sprintf("%s/app/%s", groupID, *applicationID)
191+
resourceID = fmt.Sprintf("%s/%s", groupID, strings.Join(entityIDs, ","))
192+
193+
return
162194
}
163195

164-
func ExpandGroupMembershipID(id string) (groupID string, userID string, applicationID string, err error) {
196+
// Parse the group membership resource id and return the group id and the map of entity ids by kind
197+
func ExpandGroupMembershipResourceID(id string) (groupID string, entityIDs map[EntityKind][]string, err error) {
165198
elems := strings.Split(id, "/")
166-
if len(elems) != 3 {
167-
return "", "", "", fmt.Errorf("invalid group member id format, expected {groupID}/{type}/{memberID}, got: %s", id)
199+
if len(elems) != 2 {
200+
return "", nil, fmt.Errorf("invalid group membership id format, expected {groupID}/{entityKind}:{entityIDs}, got: %s", id)
168201
}
169202

170203
groupID = elems[0]
171204

172-
switch elems[1] {
173-
case "user":
174-
userID = elems[2]
175-
case "app":
176-
applicationID = elems[2]
205+
// entityKind:entityID,entityKind:entityID
206+
entityKindAndIDs := strings.Split(elems[1], ",")
207+
entityIDs = make(map[EntityKind][]string)
208+
209+
for _, entityKindAndID := range entityKindAndIDs {
210+
splitted := strings.Split(entityKindAndID, ":")
211+
if len(splitted) != 2 {
212+
return "", nil, fmt.Errorf("invalid entity kind and id format, expected {entityKind}:{entityID}, got: %s", entityKindAndID)
213+
}
214+
215+
entityKind, entityID := EntityKind(splitted[0]), splitted[1]
216+
if entityKind != EntityKindUser && entityKind != EntityKindApplication {
217+
return "", nil, fmt.Errorf("invalid entity kind, expected %s or %s, got: %s", EntityKindUser, EntityKindApplication, entityKind)
218+
}
219+
220+
entityIDs[entityKind] = append(entityIDs[entityKind], entityID)
177221
}
178222

179223
return
180224
}
225+
226+
func MakeSetGroupMembershipRequest(ctx context.Context, api *iam.API, request *iam.SetGroupMembersRequest) (*iam.Group, error) {
227+
retryInterval := 250 * time.Millisecond
228+
maxRetries := 10
229+
230+
if transport.DefaultWaitRetryInterval != nil {
231+
retryInterval = *transport.DefaultWaitRetryInterval
232+
}
233+
234+
// the IAM API often returns a 409 when the group is in a transient state
235+
// so we retry with an exponential backoff
236+
for i := range maxRetries {
237+
response, err := api.SetGroupMembers(request, scw.WithContext(ctx))
238+
if err != nil {
239+
if httperrors.Is409(err) && strings.Contains(err.Error(), fmt.Sprintf("resource group with ID %s is in a transient state: updating", request.GroupID)) {
240+
time.Sleep(retryInterval * time.Duration(i)) // lintignore: R018
241+
242+
continue
243+
}
244+
245+
return nil, err
246+
}
247+
248+
return response, nil
249+
}
250+
251+
return nil, fmt.Errorf("failed to set group membership after %d retries", maxRetries)
252+
}

0 commit comments

Comments
 (0)