From ecc8eb6b881a0b724185992a51a1680a8508416e Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Fri, 13 Dec 2024 17:20:59 +0000 Subject: [PATCH 1/2] Add support for project members resource --- client/project_member.go | 140 +++++ docs/data-sources/project_members.md | 48 ++ docs/resources/project_members.md | 63 +++ .../vercel_project_members/data-source.tf | 7 + .../vercel_project_members/resource.tf | 15 + go.mod | 1 - go.sum | 2 - vercel/data_source_project_members.go | 128 +++++ vercel/data_source_project_members_test.go | 51 ++ vercel/provider.go | 2 + vercel/resource_project.go | 31 +- vercel/resource_project_members.go | 528 ++++++++++++++++++ vercel/resource_project_members_test.go | 105 ++++ 13 files changed, 1096 insertions(+), 25 deletions(-) create mode 100644 client/project_member.go create mode 100644 docs/data-sources/project_members.md create mode 100644 docs/resources/project_members.md create mode 100644 examples/data-sources/vercel_project_members/data-source.tf create mode 100644 examples/resources/vercel_project_members/resource.tf create mode 100644 vercel/data_source_project_members.go create mode 100644 vercel/data_source_project_members_test.go create mode 100644 vercel/resource_project_members.go create mode 100644 vercel/resource_project_members_test.go diff --git a/client/project_member.go b/client/project_member.go new file mode 100644 index 00000000..6289ed0c --- /dev/null +++ b/client/project_member.go @@ -0,0 +1,140 @@ +package client + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +type ProjectMember struct { + UserID string `json:"uid,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + Role string `json:"role"` +} + +type AddProjectMembersRequest struct { + ProjectID string `json:"-"` + TeamID string `json:"-"` + Members []ProjectMember `json:"members"` +} + +func (c *Client) AddProjectMembers(ctx context.Context, request AddProjectMembersRequest) error { + url := fmt.Sprintf("%s/v1/projects/%s/members/batch", c.baseURL, request.ProjectID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + tflog.Info(ctx, "adding project members", map[string]interface{}{ + "url": url, + }) + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "POST", + url: url, + body: string(mustMarshal(request)), + }, nil) + if err != nil { + tflog.Error(ctx, "error adding project members", map[string]interface{}{ + "url": url, + "members": request.Members, + }) + } + return err +} + +type RemoveProjectMembersRequest struct { + ProjectID string `json:"-"` + TeamID string `json:"-"` + Members []string `json:"members"` +} + +func (c *Client) RemoveProjectMembers(ctx context.Context, request RemoveProjectMembersRequest) error { + url := fmt.Sprintf("%s/v1/projects/%s/members/batch", c.baseURL, request.ProjectID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + tflog.Info(ctx, "removing project members", map[string]interface{}{ + "url": url, + }) + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "DELETE", + url: url, + body: string(mustMarshal(request)), + }, nil) + if err != nil { + tflog.Error(ctx, "error removing project members", map[string]interface{}{ + "url": url, + "members": request.Members, + }) + } + return err +} + +type UpdateProjectMemberRequest struct { + UserID string `json:"uid,omitempty"` + Role string `json:"role"` +} + +type UpdateProjectMembersRequest struct { + ProjectID string `json:"-"` + TeamID string `json:"-"` + Members []UpdateProjectMemberRequest `json:"members"` +} + +func (c *Client) UpdateProjectMembers(ctx context.Context, request UpdateProjectMembersRequest) error { + url := fmt.Sprintf("%s/v1/projects/%s/members", c.baseURL, request.ProjectID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s", url, c.teamID(request.TeamID)) + } + payload := string(mustMarshal(request)) + tflog.Info(ctx, "updating project members", map[string]interface{}{ + "url": url, + "payload": payload, + }) + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: url, + body: payload, + }, nil) + if err != nil { + tflog.Error(ctx, "error updating project members", map[string]interface{}{ + "url": url, + "members": request.Members, + }) + } + return err +} + +type GetProjectMembersRequest struct { + ProjectID string `json:"-"` + TeamID string `json:"-"` +} + +func (c *Client) ListProjectMembers(ctx context.Context, request GetProjectMembersRequest) ([]ProjectMember, error) { + url := fmt.Sprintf("%s/v1/projects/%s/members", c.baseURL, request.ProjectID) + if c.teamID(request.TeamID) != "" { + url = fmt.Sprintf("%s?teamId=%s&limit=100", url, c.teamID(request.TeamID)) + } + tflog.Info(ctx, "listing project members", map[string]interface{}{ + "url": url, + }) + + var resp struct { + Members []ProjectMember `json:"members"` + } + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "GET", + url: url, + body: string(mustMarshal(request)), + }, &resp) + if err != nil { + tflog.Error(ctx, "error getting project members", map[string]interface{}{ + "url": url, + }) + } + return resp.Members, err +} diff --git a/docs/data-sources/project_members.md b/docs/data-sources/project_members.md new file mode 100644 index 00000000..d6c727f5 --- /dev/null +++ b/docs/data-sources/project_members.md @@ -0,0 +1,48 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_members Data Source - terraform-provider-vercel" +subcategory: "" +description: |- + Retrieves members and their roles for a Vercel Project. +--- + +# vercel_project_members (Data Source) + +Retrieves members and their roles for a Vercel Project. + +## Example Usage + +```terraform +data "vercel_project" "example" { + name = "example-with-members" +} + +data "vercel_project_members" "example" { + project_id = data.vercel_project.example.id +} +``` + + +## Schema + +### Required + +- `project_id` (String) The ID of the Vercel Project. + +### Optional + +- `team_id` (String) The team ID to which the project belongs. Required when accessing a team project if a default team has not been set in the provider. + +### Read-Only + +- `members` (Attributes Set) The set of members in this project. (see [below for nested schema](#nestedatt--members)) + + +### Nested Schema for `members` + +Read-Only: + +- `email` (String) The email of the user. +- `role` (String) The role of the user in the project. One of 'MEMBER', 'PROJECT_DEVELOPER', or 'PROJECT_VIEWER'. +- `user_id` (String) The ID of the user. +- `username` (String) The username of the user. diff --git a/docs/resources/project_members.md b/docs/resources/project_members.md new file mode 100644 index 00000000..f248f772 --- /dev/null +++ b/docs/resources/project_members.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vercel_project_members Resource - terraform-provider-vercel" +subcategory: "" +description: |- + Manages members and their roles for a Vercel Project. + ~> Note that this resource does not manage the complete set of members for a project, only the members that + are explicitly configured here. This is deliberately done to allow granular additions. + This, however, means config drift will not be detected for members that are added or removed outside of terraform. +--- + +# vercel_project_members (Resource) + +Manages members and their roles for a Vercel Project. + +~> Note that this resource does not manage the complete set of members for a project, only the members that +are explicitly configured here. This is deliberately done to allow granular additions. +This, however, means config drift will not be detected for members that are added or removed outside of terraform. + +## Example Usage + +```terraform +resource "vercel_project" "example" { + name = "example-with-members" +} + +resource "vercel_project_members" "example" { + project_id = vercel_project.example.id + + members = [{ + email = "user@example.com" + role = "PROJECT_VIEWER" + }, { + username = "some-example-user" + role = "PROJECT_DEVELOPER" + }] +} +``` + + +## Schema + +### Required + +- `members` (Attributes Set) The set of members to manage for this project. (see [below for nested schema](#nestedatt--members)) +- `project_id` (String) The ID of the existing Vercel Project. + +### Optional + +- `team_id` (String) The team ID to add the project to. Required when configuring a team resource if a default team has not been set in the provider. + + +### Nested Schema for `members` + +Required: + +- `role` (String) The role that the user should have in the project. One of 'MEMBER', 'PROJECT_DEVELOPER', or 'PROJECT_VIEWER'. + +Optional: + +- `email` (String) The email of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified. +- `user_id` (String) The ID of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified. +- `username` (String) The username of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified. diff --git a/examples/data-sources/vercel_project_members/data-source.tf b/examples/data-sources/vercel_project_members/data-source.tf new file mode 100644 index 00000000..1344bb0e --- /dev/null +++ b/examples/data-sources/vercel_project_members/data-source.tf @@ -0,0 +1,7 @@ +data "vercel_project" "example" { + name = "example-with-members" +} + +data "vercel_project_members" "example" { + project_id = data.vercel_project.example.id +} diff --git a/examples/resources/vercel_project_members/resource.tf b/examples/resources/vercel_project_members/resource.tf new file mode 100644 index 00000000..56a70a01 --- /dev/null +++ b/examples/resources/vercel_project_members/resource.tf @@ -0,0 +1,15 @@ +resource "vercel_project" "example" { + name = "example-with-members" +} + +resource "vercel_project_members" "example" { + project_id = vercel_project.example.id + + members = [{ + email = "user@example.com" + role = "PROJECT_VIEWER" + }, { + username = "some-example-user" + role = "PROJECT_DEVELOPER" + }] +} diff --git a/go.mod b/go.mod index 38ba2695..af6d1093 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,6 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.1.0 // indirect - github.com/vercel/terraform-provider-vercel v1.14.1 github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index f4517c11..09e5b437 100644 --- a/go.sum +++ b/go.sum @@ -140,8 +140,6 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/vercel/terraform-provider-vercel v1.14.1 h1:ghAjFkMMzka4XuoBYdu1OXM/K7FQEj8wUd+xMPPOGrg= -github.com/vercel/terraform-provider-vercel v1.14.1/go.mod h1:AdFCiUD0XP8XOi6tnhaCh7I0vyq2TAPmI+GcIp3+7SI= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= diff --git a/vercel/data_source_project_members.go b/vercel/data_source_project_members.go new file mode 100644 index 00000000..70c55efb --- /dev/null +++ b/vercel/data_source_project_members.go @@ -0,0 +1,128 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/vercel/terraform-provider-vercel/v2/client" +) + +var ( + _ datasource.DataSource = &projectMembersDataSource{} + _ datasource.DataSourceWithConfigure = &projectMembersDataSource{} +) + +func newProjectMembersDataSource() datasource.DataSource { + return &projectMembersDataSource{} +} + +type projectMembersDataSource struct { + client *client.Client +} + +func (d *projectMembersDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_members" +} + +func (d *projectMembersDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + d.client = client +} + +func (d *projectMembersDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Retrieves members and their roles for a Vercel Project.", + Attributes: map[string]schema.Attribute{ + "team_id": schema.StringAttribute{ + Optional: true, + Description: "The team ID to which the project belongs. Required when accessing a team project if a default team has not been set in the provider.", + }, + "project_id": schema.StringAttribute{ + Required: true, + Description: "The ID of the Vercel Project.", + }, + "members": schema.SetNestedAttribute{ + Computed: true, + Description: "The set of members in this project.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "user_id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the user.", + }, + "email": schema.StringAttribute{ + Computed: true, + Description: "The email of the user.", + }, + "username": schema.StringAttribute{ + Computed: true, + Description: "The username of the user.", + }, + "role": schema.StringAttribute{ + Computed: true, + Description: "The role of the user in the project. One of 'MEMBER', 'PROJECT_DEVELOPER', or 'PROJECT_VIEWER'.", + }, + }, + }, + }, + }, + } +} + +type ProjectMembersDataSourceModel struct { + TeamID types.String `tfsdk:"team_id"` + ProjectID types.String `tfsdk:"project_id"` + Members types.Set `tfsdk:"members"` +} + +func (d *projectMembersDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config ProjectMembersDataSourceModel + diags := req.Config.Get(ctx, &config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + members, err := d.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{ + TeamID: config.TeamID.ValueString(), + ProjectID: config.ProjectID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error reading Project Members", + fmt.Sprintf("Could not read Project Members, unexpected error: %s", err), + ) + return + } + + // Convert API response to model + var memberItems []attr.Value + for _, member := range members { + memberItems = append(memberItems, types.ObjectValueMust(memberAttrType.AttrTypes, map[string]attr.Value{ + "user_id": types.StringValue(member.UserID), + "email": types.StringValue(member.Email), + "username": types.StringValue(member.Username), + "role": types.StringValue(member.Role), + })) + } + + config.Members = types.SetValueMust(memberAttrType, memberItems) + diags = resp.State.Set(ctx, &config) + resp.Diagnostics.Append(diags...) +} diff --git a/vercel/data_source_project_members_test.go b/vercel/data_source_project_members_test.go new file mode 100644 index 00000000..404a6fbc --- /dev/null +++ b/vercel/data_source_project_members_test.go @@ -0,0 +1,51 @@ +package vercel_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccProjectMembersDataSource(t *testing.T) { + projectSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccProjectDestroy("vercel_project.test", testTeam()), + Steps: []resource.TestStep{ + { + Config: testAccProjectMembersDataSourceConfig(projectSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("data.vercel_project_members.test", "project_id"), + resource.TestCheckResourceAttr("data.vercel_project_members.test", "members.#", "1"), + ), + }, + }, + }) +} + +func testAccProjectMembersDataSourceConfig(projectSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-project-members-%[1]s" + %[2]s +} + +resource "vercel_project_members" "test" { + project_id = vercel_project.test.id + %[2]s + + members = [{ + email = "doug+test2@vercel.com" + role = "PROJECT_VIEWER" + }] +} + +data "vercel_project_members" "test" { + project_id = vercel_project_members.test.project_id + %[2]s +} +`, projectSuffix, teamIDConfig()) +} diff --git a/vercel/provider.go b/vercel/provider.go index 25d166be..a0a54e17 100644 --- a/vercel/provider.go +++ b/vercel/provider.go @@ -66,6 +66,7 @@ func (p *vercelProvider) Resources(_ context.Context) []func() resource.Resource newProjectDomainResource, newProjectEnvironmentVariableResource, newProjectEnvironmentVariablesResource, + newProjectMembersResource, newProjectResource, newSharedEnvironmentVariableResource, newTeamConfigResource, @@ -92,6 +93,7 @@ func (p *vercelProvider) DataSources(_ context.Context) []func() datasource.Data newProjectDataSource, newProjectDeploymentRetentionDataSource, newProjectDirectoryDataSource, + newProjectMembersDataSource, newSharedEnvironmentVariableDataSource, newTeamConfigDataSource, newTeamMemberDataSource, diff --git a/vercel/resource_project.go b/vercel/resource_project.go index 60cf2523..f21251b6 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -1217,28 +1217,15 @@ func convertResponseToProject(ctx context.Context, response client.ProjectRespon } } - env = append(env, types.ObjectValueMust( - map[string]attr.Type{ - "key": types.StringType, - "value": types.StringType, - "target": types.SetType{ - ElemType: types.StringType, - }, - "git_branch": types.StringType, - "id": types.StringType, - "sensitive": types.BoolType, - "comment": types.StringType, - }, - map[string]attr.Value{ - "key": types.StringValue(e.Key), - "value": value, - "target": types.SetValueMust(types.StringType, target), - "git_branch": types.StringPointerValue(e.GitBranch), - "id": types.StringValue(e.ID), - "sensitive": types.BoolValue(e.Type == "sensitive"), - "comment": types.StringValue(e.Comment), - }, - )) + env = append(env, types.ObjectValueMust(envVariableElemType.AttrTypes, map[string]attr.Value{ + "key": types.StringValue(e.Key), + "value": value, + "target": types.SetValueMust(types.StringType, target), + "git_branch": types.StringPointerValue(e.GitBranch), + "id": types.StringValue(e.ID), + "sensitive": types.BoolValue(e.Type == "sensitive"), + "comment": types.StringValue(e.Comment), + })) } protectionBypassSecret := types.StringNull() diff --git a/vercel/resource_project_members.go b/vercel/resource_project_members.go new file mode 100644 index 00000000..6ad2d5ee --- /dev/null +++ b/vercel/resource_project_members.go @@ -0,0 +1,528 @@ +package vercel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/vercel/terraform-provider-vercel/v2/client" +) + +var ( + _ resource.Resource = &projectMembersResource{} + _ resource.ResourceWithConfigure = &projectMembersResource{} +) + +func newProjectMembersResource() resource.Resource { + return &projectMembersResource{} +} + +type projectMembersResource struct { + client *client.Client +} + +func (r *projectMembersResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_members" +} + +func (r *projectMembersResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *projectMembersResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Manages members and their roles for a Vercel Project. + +~> Note that this resource does not manage the complete set of members for a project, only the members that +are explicitly configured here. This is deliberately done to allow granular additions. +This, however, means config drift will not be detected for members that are added or removed outside of terraform. +`, + Attributes: map[string]schema.Attribute{ + "team_id": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplaceIfConfigured(), stringplanmodifier.UseStateForUnknown()}, + Description: "The team ID to add the project to. Required when configuring a team resource if a default team has not been set in the provider.", + }, + "project_id": schema.StringAttribute{ + Description: "The ID of the existing Vercel Project.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "members": schema.SetNestedAttribute{ + Description: "The set of members to manage for this project.", + Required: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "user_id": schema.StringAttribute{ + Description: "The ID of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("email"), + path.MatchRelative().AtParent().AtName("username"), + ), + }, + }, + "email": schema.StringAttribute{ + Description: "The email of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("user_id"), + path.MatchRelative().AtParent().AtName("username"), + ), + }, + }, + "username": schema.StringAttribute{ + Description: "The username of the user to add to the project. Exactly one of `user_id`, `email`, or `username` must be specified.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf( + path.MatchRelative().AtParent().AtName("user_id"), + path.MatchRelative().AtParent().AtName("email"), + ), + }, + }, + "role": schema.StringAttribute{ + Description: "The role that the user should have in the project. One of 'MEMBER', 'PROJECT_DEVELOPER', or 'PROJECT_VIEWER'.", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("MEMBER", "PROJECT_DEVELOPER", "PROJECT_VIEWER"), + }, + }, + }, + }, + }, + }, + } +} + +type ProjectMembersModel struct { + TeamID types.String `tfsdk:"team_id"` + ProjectID types.String `tfsdk:"project_id"` + Members types.Set `tfsdk:"members"` +} + +type ProjectMemberItem struct { + UserID types.String `tfsdk:"user_id"` + Email types.String `tfsdk:"email"` + Username types.String `tfsdk:"username"` + Role types.String `tfsdk:"role"` +} + +func (m ProjectMembersModel) members(ctx context.Context) ([]ProjectMemberItem, diag.Diagnostics) { + var members []ProjectMemberItem + diags := m.Members.ElementsAs(ctx, &members, false) + return members, diags +} + +var memberAttrType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "user_id": types.StringType, + "email": types.StringType, + "username": types.StringType, + "role": types.StringType, + }, +} + +func (r *projectMembersResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan ProjectMembersModel + diags := req.Plan.Get(ctx, &plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + planMembers, diags := plan.members(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + var requestMembers []client.ProjectMember + for _, m := range planMembers { + requestMembers = append(requestMembers, client.ProjectMember{ + UserID: m.UserID.ValueString(), + Username: m.Username.ValueString(), + Email: m.Email.ValueString(), + Role: m.Role.ValueString(), + }) + } + + err := r.client.AddProjectMembers(ctx, client.AddProjectMembersRequest{ + ProjectID: plan.ProjectID.ValueString(), + TeamID: plan.TeamID.ValueString(), + Members: requestMembers, + }) + if err != nil { + resp.Diagnostics.AddError( + "Error adding Project Members", + fmt.Sprintf("Could not add Project Members, unexpected error: %s", err), + ) + return + } + + members, err := r.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{ + TeamID: plan.TeamID.ValueString(), + ProjectID: plan.ProjectID.ValueString(), + }) + tflog.Trace(ctx, "read project members", map[string]interface{}{ + "team_id": plan.TeamID.ValueString(), + "project_id": plan.ProjectID.ValueString(), + "members": members, + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading Project Members", + fmt.Sprintf("Could not read Project Members, unexpected error: %s", err), + ) + return + } + + // Convert API response to model + var memberItems []attr.Value + for _, member := range members { + if terraformHasMember(planMembers, member) { + memberItems = append(memberItems, types.ObjectValueMust(memberAttrType.AttrTypes, map[string]attr.Value{ + "user_id": types.StringValue(member.UserID), + "email": types.StringValue(member.Email), + "username": types.StringValue(member.Username), + "role": types.StringValue(member.Role), + })) + } + } + + plan.Members = types.SetValueMust(memberAttrType, memberItems) + diags = resp.State.Set(ctx, &plan) + resp.Diagnostics.Append(diags...) +} + +// diffMembers compares the state and planned members to determine which members need to be added, removed, or updated +func diffMembers(stateMembers, plannedMembers []ProjectMemberItem) (toAdd, toRemove, toUpdate []ProjectMemberItem) { + stateMap := map[string]ProjectMemberItem{} + plannedMap := map[string]ProjectMemberItem{} + + for _, member := range stateMembers { + stateMap[member.UserID.ValueString()] = member + } + + for _, member := range plannedMembers { + stateMember, inState := stateMap[member.UserID.ValueString()] + if member.UserID.IsUnknown() || member.Email.IsUnknown() || member.Username.IsUnknown() || !inState { + // Then the member hasn't been created yet, so add it. + toAdd = append(toAdd, member) + continue + } + if _, ok := stateMap[member.UserID.ValueString()]; !ok { + // Then the member hasn't been created yet, so add it. + toAdd = append(toAdd, member) + continue + } + + // Add to planned, so we can reverse look up ones to remove later. + plannedMap[member.UserID.ValueString()] = member + if inState && stateMember.Role != member.Role { + toUpdate = append(toUpdate, member) + } + } + + // Find members to remove (in state but not in plan) + for key, member := range stateMap { + if _, exists := plannedMap[key]; !exists { + toRemove = append(toRemove, member) + } + } + + return toAdd, toRemove, toUpdate +} + +func terraformHasMember(stateMembers []ProjectMemberItem, member client.ProjectMember) bool { + for _, m := range stateMembers { + if m.UserID.ValueString() == member.UserID || m.Email.ValueString() == member.Email || m.Username.ValueString() == member.Username { + return true + } + } + return false +} + +func (r *projectMembersResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state ProjectMembersModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + stateMembers, diags := state.members(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + members, err := r.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{ + TeamID: state.TeamID.ValueString(), + ProjectID: state.ProjectID.ValueString(), + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + } + if err != nil { + resp.Diagnostics.AddError( + "Error reading Project Members", + fmt.Sprintf("Could not read Project Members, unexpected error: %s", err), + ) + return + } + + // Convert API response to model + var memberItems []attr.Value + for _, member := range members { + if terraformHasMember(stateMembers, member) { + memberItems = append(memberItems, types.ObjectValueMust(memberAttrType.AttrTypes, map[string]attr.Value{ + "user_id": types.StringValue(member.UserID), + "email": types.StringValue(member.Email), + "username": types.StringValue(member.Username), + "role": types.StringValue(member.Role), + })) + } + } + + state.Members = types.SetValueMust(memberAttrType, memberItems) + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *projectMembersResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state ProjectMembersModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + planMembers, diags := plan.members(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + // Get current members + currentMembers, err := r.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{ + ProjectID: plan.ProjectID.ValueString(), + TeamID: plan.TeamID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error reading current Project Members", + fmt.Sprintf("Could not read current Project Members: %s", err), + ) + return + } + + // Create a map of current members for easy lookup + currentMemberMap := make(map[string]client.ProjectMember) + for _, member := range currentMembers { + currentMemberMap[member.UserID] = member + } + + // Process planned members + plannedMembers, diags := plan.members(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + stateMembers, diags := state.members(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + toAdd, toRemove, toUpdate := diffMembers(stateMembers, plannedMembers) + tflog.Info(ctx, "update project members", map[string]interface{}{ + "toAdd": toAdd, + "toRemove": toRemove, + "toUpdate": toUpdate, + }) + + // Remove members that are no longer in the plan + var remove []string + for _, r := range toRemove { + remove = append(remove, r.UserID.ValueString()) + } + + if len(remove) > 0 { + err = r.client.RemoveProjectMembers(ctx, client.RemoveProjectMembersRequest{ + ProjectID: plan.ProjectID.ValueString(), + TeamID: plan.TeamID.ValueString(), + Members: remove, + }) + if err != nil { + resp.Diagnostics.AddError( + "Error removing Project Members", + fmt.Sprintf("Could not remove Project Members: %s", err), + ) + return + } + } + + var add []client.ProjectMember + for _, a := range toAdd { + add = append(add, client.ProjectMember{ + UserID: a.UserID.ValueString(), + Username: a.Username.ValueString(), + Email: a.Email.ValueString(), + Role: a.Role.ValueString(), + }) + } + if len(add) > 0 { + err = r.client.AddProjectMembers(ctx, client.AddProjectMembersRequest{ + ProjectID: plan.ProjectID.ValueString(), + TeamID: plan.TeamID.ValueString(), + Members: add, + }) + if err != nil { + resp.Diagnostics.AddError( + "Error adding Project Members", + fmt.Sprintf("Could not add Project Members: %s", err), + ) + return + } + } + + var update []client.UpdateProjectMemberRequest + for _, u := range toUpdate { + update = append(update, client.UpdateProjectMemberRequest{ + UserID: u.UserID.ValueString(), + Role: u.Role.ValueString(), + }) + } + if len(update) > 0 { + err = r.client.UpdateProjectMembers(ctx, client.UpdateProjectMembersRequest{ + ProjectID: plan.ProjectID.ValueString(), + TeamID: plan.TeamID.ValueString(), + Members: update, + }) + + if err != nil { + resp.Diagnostics.AddError( + "Error updating Project Members", + fmt.Sprintf("Could not update Project Members: %s", err), + ) + return + } + } + + members, err := r.client.ListProjectMembers(ctx, client.GetProjectMembersRequest{ + TeamID: state.TeamID.ValueString(), + ProjectID: state.ProjectID.ValueString(), + }) + tflog.Info(ctx, "read project members", map[string]interface{}{ + "team_id": state.TeamID.ValueString(), + "project_id": state.ProjectID.ValueString(), + "members": members, + }) + if err != nil { + resp.Diagnostics.AddError( + "Error reading Project Members", + fmt.Sprintf("Could not read Project Members, unexpected error: %s", err), + ) + return + } + + // Convert API response to model + var memberItems []attr.Value + for _, member := range members { + if terraformHasMember(planMembers, member) { + memberItems = append(memberItems, types.ObjectValueMust(memberAttrType.AttrTypes, map[string]attr.Value{ + "user_id": types.StringValue(member.UserID), + "email": types.StringValue(member.Email), + "username": types.StringValue(member.Username), + "role": types.StringValue(member.Role), + })) + } + } + + state.Members = types.SetValueMust(memberAttrType, memberItems) + diags = resp.State.Set(ctx, &state) + resp.Diagnostics.Append(diags...) +} + +func (r *projectMembersResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state ProjectMembersModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + members, diags := state.members(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + var remove []string + for _, m := range members { + remove = append(remove, m.UserID.ValueString()) + } + + err := r.client.RemoveProjectMembers(ctx, client.RemoveProjectMembersRequest{ + ProjectID: state.ProjectID.ValueString(), + TeamID: state.TeamID.ValueString(), + Members: remove, + }) + if client.NotFound(err) { + resp.State.RemoveResource(ctx) + } + if err != nil { + resp.Diagnostics.AddError( + "Error removing Project Members", + fmt.Sprintf("Could not remove Project Members: %s", err), + ) + return + } + +} diff --git a/vercel/resource_project_members_test.go b/vercel/resource_project_members_test.go new file mode 100644 index 00000000..0a92c2da --- /dev/null +++ b/vercel/resource_project_members_test.go @@ -0,0 +1,105 @@ +package vercel_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccProjectMembers(t *testing.T) { + projectSuffix := acctest.RandString(16) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + CheckDestroy: testAccProjectDestroy("vercel_project.test", testTeam()), + Steps: []resource.TestStep{ + { + Config: testAccProjectMembersConfig(projectSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("vercel_project_members.test", "project_id"), + resource.TestCheckResourceAttr("vercel_project_members.test", "members.#", "1"), + ), + }, + { + Config: testAccProjectMembersConfigUpdated(projectSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("vercel_project_members.test", "project_id"), + resource.TestCheckResourceAttr("vercel_project_members.test", "members.#", "2"), + ), + }, + { + Config: testAccProjectMembersConfigUpdatedAgain(projectSuffix), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("vercel_project_members.test", "project_id"), + resource.TestCheckResourceAttr("vercel_project_members.test", "members.#", "1"), + ), + }, + }, + }) +} + +func testAccProjectMembersConfig(projectSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-project-members-%[1]s" + %[2]s +} + +resource "vercel_project_members" "test" { + project_id = vercel_project.test.id + %[2]s + + members = [{ + email = "doug+test2@vercel.com" + role = "PROJECT_VIEWER" + }] +} +`, projectSuffix, teamIDConfig()) +} + +func testAccProjectMembersConfigUpdated(projectSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-project-members-%[1]s" + %[2]s +} + +resource "vercel_project_members" "test" { + project_id = vercel_project.test.id + %[2]s + + members = [{ + email = "doug+test2@vercel.com" + role = "PROJECT_DEVELOPER" + }, + { + email = "doug+test3@vercel.com" + role = "PROJECT_VIEWER" + } + ] +} +`, projectSuffix, teamIDConfig()) +} + +func testAccProjectMembersConfigUpdatedAgain(projectSuffix string) string { + return fmt.Sprintf(` +resource "vercel_project" "test" { + name = "test-acc-project-members-%[1]s" + %[2]s +} + +resource "vercel_project_members" "test" { + project_id = vercel_project.test.id + %[2]s + + members = [ + { + email = "doug+test3@vercel.com" + role = "PROJECT_VIEWER" + } + ] +} +`, projectSuffix, teamIDConfig()) +} From 097c83c970b3b0889e8a1f9b80c76edb3b1e8b52 Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Mon, 16 Dec 2024 17:00:54 +0000 Subject: [PATCH 2/2] Fix test name lint --- vercel/data_source_project_members_test.go | 2 +- vercel/resource_project_members_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vercel/data_source_project_members_test.go b/vercel/data_source_project_members_test.go index 404a6fbc..64aae87a 100644 --- a/vercel/data_source_project_members_test.go +++ b/vercel/data_source_project_members_test.go @@ -8,7 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) -func TestAccProjectMembersDataSource(t *testing.T) { +func TestAcc_ProjectMembersDataSource(t *testing.T) { projectSuffix := acctest.RandString(16) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) }, diff --git a/vercel/resource_project_members_test.go b/vercel/resource_project_members_test.go index 0a92c2da..d54d6b9e 100644 --- a/vercel/resource_project_members_test.go +++ b/vercel/resource_project_members_test.go @@ -8,7 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) -func TestAccProjectMembers(t *testing.T) { +func TestAcc_ProjectMembers(t *testing.T) { projectSuffix := acctest.RandString(16) resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) },