Skip to content

Commit 4347c6e

Browse files
authored
Handle project members (#531)
* deprecate members field and make it valid only in creation * remove owner and members from datasource * Revert "remove owner and members from datasource" This reverts commit 31d2302. * update acc test * add creation limitation in members description --------- Co-authored-by: Gökçe Gök Klingel <goekce.goek_klingel@stackit.cloud>
1 parent 4de8552 commit 4347c6e

5 files changed

Lines changed: 39 additions & 489 deletions

File tree

docs/resources/resourcemanager_project.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,17 @@ resource "stackit_resourcemanager_project" "example" {
2424
```
2525

2626
<!-- schema generated by tfplugindocs -->
27-
2827
## Schema
2928

3029
### Required
3130

3231
- `name` (String) Project name.
32+
- `owner_email` (String) Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.
3333
- `parent_container_id` (String) Parent resource identifier. Both container ID (user-friendly) and UUID are supported
3434

3535
### Optional
3636

3737
- `labels` (Map of String) Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}
38-
- `owner_email` (String) Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.
3938

4039
### Read-Only
4140

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

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -102,17 +102,18 @@ func (d *projectDataSource) Configure(ctx context.Context, req datasource.Config
102102
// Schema defines the schema for the data source.
103103
func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
104104
descriptions := map[string]string{
105-
"main": "Resource Manager project data source schema. To identify the project, you need to provider either project_id or container_id. If you provide both, project_id will be used.",
106-
"id": "Terraform's internal data source. ID. It is structured as \"`container_id`\".",
107-
"project_id": "Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project.",
108-
"container_id": "Project container ID. Globally unique, user-friendly identifier.",
109-
"parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported",
110-
"name": "Project name.",
111-
"labels": `Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`,
112-
"owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.",
113-
"members": "The members assigned to the project. At least one subject needs to be a user, and not a client or service account.",
114-
"members.role": fmt.Sprintf("The role of the member in the project. Legacy roles (%s) are not supported.", strings.Join(utils.QuoteValues(utils.LegacyProjectRoles), ", ")),
115-
"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.",
105+
"main": "Resource Manager project data source schema. To identify the project, you need to provider either project_id or container_id. If you provide both, project_id will be used.",
106+
"id": "Terraform's internal data source. ID. It is structured as \"`container_id`\".",
107+
"project_id": "Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project.",
108+
"container_id": "Project container ID. Globally unique, user-friendly identifier.",
109+
"parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported",
110+
"name": "Project name.",
111+
"labels": `Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`,
112+
"owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.",
113+
"members": "The members assigned to the project. At least one subject needs to be a user, and not a client or service account. This value is only considered during creation. Changing it afterwards will have no effect.",
114+
"members.role": fmt.Sprintf("The role of the member in the project. Legacy roles (%s) are not supported.", strings.Join(utils.QuoteValues(utils.LegacyProjectRoles), ", ")),
115+
"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.",
116+
"members_deprecation_message": "The \"members\" field has been deprecated in favor of the \"owner_email\" field. Please use the \"owner_email\" field to assign the owner role to a user.",
116117
}
117118

118119
resp.Schema = schema.Schema{
@@ -173,8 +174,10 @@ func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest
173174
Optional: true,
174175
},
175176
"members": schema.ListNestedAttribute{
176-
Description: descriptions["members"],
177-
Computed: true,
177+
Description: descriptions["members"],
178+
DeprecationMessage: descriptions["members_deprecation_message"],
179+
MarkdownDescription: fmt.Sprintf("%s\n\n!> %s", descriptions["members"], descriptions["members_deprecation_message"]),
180+
Computed: true,
178181
NestedObject: schema.NestedAttributeObject{
179182
Attributes: map[string]schema.Attribute{
180183
"role": schema.StringAttribute{
@@ -237,17 +240,6 @@ func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest
237240
return
238241
}
239242

240-
membersResp, err := d.membershipClient.ListMembersExecute(ctx, projectResourceType, *projectResp.ProjectId)
241-
if err != nil {
242-
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Reading members: %v", err))
243-
return
244-
}
245-
246-
err = mapMembersFields(ctx, membersResp.Members, &model)
247-
if err != nil {
248-
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API response: %v", err))
249-
return
250-
}
251243
diags = resp.State.Set(ctx, &model)
252244
resp.Diagnostics.Append(diags...)
253245
if resp.Diagnostics.HasError() {

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

Lines changed: 18 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -147,17 +147,18 @@ func (r *projectResource) Configure(ctx context.Context, req resource.ConfigureR
147147
// Schema defines the schema for the resource.
148148
func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
149149
descriptions := map[string]string{
150-
"main": "Resource Manager project resource schema. To use this resource, it is required that you set the service account email in the provider configuration.",
151-
"id": "Terraform's internal resource ID. It is structured as \"`container_id`\".",
152-
"project_id": "Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project.",
153-
"container_id": "Project container ID. Globally unique, user-friendly identifier.",
154-
"parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported",
155-
"name": "Project name.",
156-
"labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}",
157-
"owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.",
158-
"members": "The members assigned to the project. At least one subject needs to be a user, and not a client or service account.",
159-
"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), ", ")),
160-
"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.",
150+
"main": "Resource Manager project resource schema. To use this resource, it is required that you set the service account email in the provider configuration.",
151+
"id": "Terraform's internal resource ID. It is structured as \"`container_id`\".",
152+
"project_id": "Project UUID identifier. This is the ID that can be used in most of the other resources to identify the project.",
153+
"container_id": "Project container ID. Globally unique, user-friendly identifier.",
154+
"parent_container_id": "Parent resource identifier. Both container ID (user-friendly) and UUID are supported",
155+
"name": "Project name.",
156+
"labels": "Labels are key-value string pairs which can be attached to a resource container. A label key must match the regex [A-ZÄÜÖa-zäüöß0-9_-]{1,64}. A label value must match the regex ^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}",
157+
"owner_email": "Email address of the owner of the project. This value is only considered during creation. Changing it afterwards will have no effect.",
158+
"members": "The members assigned to the project. At least one subject needs to be a user, and not a client or service account. This value is only considered during creation. Changing it afterwards will have no effect.",
159+
"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), ", ")),
160+
"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.",
161+
"members_deprecation_message": "The \"members\" field has been deprecated in favor of the \"owner_email\" field. Please use the \"owner_email\" field to assign the owner role to a user.",
161162
}
162163

163164
resp.Schema = schema.Schema{
@@ -224,12 +225,13 @@ func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, re
224225
},
225226
"owner_email": schema.StringAttribute{
226227
Description: descriptions["owner_email"],
227-
// When removing the owner_email field, we should mark the members field as required and add a listvalidator.SizeAtLeast(1) validator to it
228-
Optional: true,
228+
Required: true,
229229
},
230230
"members": schema.ListNestedAttribute{
231-
Description: descriptions["members"],
232-
Optional: true,
231+
Description: descriptions["members"],
232+
DeprecationMessage: descriptions["members_deprecation_message"],
233+
MarkdownDescription: fmt.Sprintf("%s\n\n!> %s", descriptions["members"], descriptions["members_deprecation_message"]),
234+
Optional: true,
233235
NestedObject: schema.NestedAttributeObject{
234236
Attributes: map[string]schema.Attribute{
235237
"role": schema.StringAttribute{
@@ -393,17 +395,6 @@ func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, re
393395
return
394396
}
395397

396-
membersResp, err := r.authorizationClient.ListMembersExecute(ctx, projectResourceType, *projectResp.ProjectId)
397-
if err != nil {
398-
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Reading members: %v", err))
399-
return
400-
}
401-
402-
err = mapMembersFields(ctx, membersResp.Members, &model)
403-
if err != nil {
404-
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading project", fmt.Sprintf("Processing API response: %v", err))
405-
return
406-
}
407398
// Set refreshed model
408399
diags = resp.State.Set(ctx, model)
409400
resp.Diagnostics.Append(diags...)
@@ -451,23 +442,6 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest
451442
return
452443
}
453444

454-
members, err := toMembersPayload(ctx, &model)
455-
if err != nil {
456-
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Processing members: %v", err))
457-
return
458-
}
459-
460-
err = updateMembers(ctx, *projectResp.ProjectId, members, r.authorizationClient)
461-
if err != nil {
462-
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Updating members: %v", err))
463-
return
464-
}
465-
466-
err = mapMembersFields(ctx, members, &model)
467-
if err != nil {
468-
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating project", fmt.Sprintf("Processing API response: %v", err))
469-
return
470-
}
471445
diags = resp.State.Set(ctx, model)
472446
resp.Diagnostics.Append(diags...)
473447
if resp.Diagnostics.HasError() {
@@ -690,7 +664,7 @@ func toMembersPayload(ctx context.Context, model *Model) (*[]authorization.Membe
690664
return nil, core.DiagsToError(diags)
691665
}
692666

693-
// If the new "members" fields is set, it has precedence over the deprecated "owner_email" field
667+
// If the new "members" fields is set, it has precedence over the "owner_email" field
694668
members := []authorization.Member{}
695669
for _, m := range membersModel {
696670
members = append(members, authorization.Member{
@@ -756,102 +730,6 @@ func toUpdatePayload(model *Model) (*resourcemanager.PartialUpdateProjectPayload
756730
}, nil
757731
}
758732

759-
// updateMembers adds and removes members to match the model
760-
func updateMembers(ctx context.Context, projectId string, modelMembers *[]authorization.Member, client *authorization.APIClient) error {
761-
if modelMembers == nil || len(*modelMembers) == 0 {
762-
return nil
763-
}
764-
765-
// Get current members
766-
currentMembersResp, err := client.ListMembersExecute(ctx, projectResourceType, projectId)
767-
if err != nil {
768-
return fmt.Errorf("get members: %w", err)
769-
}
770-
771-
type memberState struct {
772-
isInModel bool
773-
isCreated bool
774-
subject string
775-
role string
776-
}
777-
778-
membersState := make(map[string]*memberState) // Key in the form of "subject,role"
779-
for _, m := range *modelMembers {
780-
mId := memberId(m)
781-
membersState[mId] = &memberState{
782-
isInModel: true,
783-
subject: *m.Subject,
784-
role: *m.Role,
785-
}
786-
}
787-
788-
for _, m := range *currentMembersResp.Members {
789-
if utils.IsLegacyProjectRole(*m.Role) {
790-
continue
791-
}
792-
793-
mId := memberId(m)
794-
_, ok := membersState[mId]
795-
if !ok {
796-
membersState[mId] = &memberState{}
797-
}
798-
membersState[mId].isCreated = true
799-
membersState[mId].subject = *m.Subject
800-
membersState[mId].role = *m.Role
801-
}
802-
803-
// Add/remove members
804-
membersToAdd := make([]authorization.Member, 0)
805-
membersToRemove := make([]authorization.Member, 0)
806-
for _, state := range membersState {
807-
if state.isInModel && !state.isCreated {
808-
m := authorization.Member{
809-
Subject: &state.subject,
810-
Role: &state.role,
811-
}
812-
membersToAdd = append(membersToAdd, m)
813-
814-
infoMsg := fmt.Sprintf("### Will add member to project: { role: %s, subject: %s }", state.role, state.subject)
815-
tflog.Warn(ctx, infoMsg)
816-
}
817-
818-
if !state.isInModel && state.isCreated {
819-
m := authorization.Member{
820-
Subject: &state.subject,
821-
Role: &state.role,
822-
}
823-
membersToRemove = append(membersToRemove, m)
824-
825-
infoMsg := fmt.Sprintf("### Will remove member from project: { role: %s, subject: %s }", state.role, state.subject)
826-
tflog.Warn(ctx, infoMsg)
827-
}
828-
}
829-
830-
if len(membersToAdd) > 0 {
831-
payload := authorization.AddMembersPayload{
832-
Members: &membersToAdd,
833-
ResourceType: sdkUtils.Ptr(projectResourceType),
834-
}
835-
_, err := client.AddMembers(ctx, projectId).AddMembersPayload(payload).Execute()
836-
if err != nil {
837-
return fmt.Errorf("add members: %w", err)
838-
}
839-
}
840-
841-
if len(membersToRemove) > 0 {
842-
payload := authorization.RemoveMembersPayload{
843-
Members: &membersToRemove,
844-
ResourceType: sdkUtils.Ptr(projectResourceType),
845-
}
846-
_, err := client.RemoveMembers(ctx, projectId).RemoveMembersPayload(payload).Execute()
847-
if err != nil {
848-
return fmt.Errorf("remove members: %w", err)
849-
}
850-
}
851-
852-
return nil
853-
}
854-
855733
// Internal representation of a member, which is uniquely identified by the subject and role
856734
func memberId(member authorization.Member) string {
857735
return fmt.Sprintf("%s,%s", *member.Subject, *member.Role)

0 commit comments

Comments
 (0)