From d81313931a16c786da4702728668da117f75d4ec Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Fri, 13 Dec 2024 17:20:59 +0000 Subject: [PATCH] Start fleshing out project members resource --- client/project_member.go | 138 +++++++++ vercel/provider.go | 1 + vercel/resource_project.go | 31 +- vercel/resource_project_members.go | 450 +++++++++++++++++++++++++++++ 4 files changed, 598 insertions(+), 22 deletions(-) create mode 100644 client/project_member.go create mode 100644 vercel/resource_project_members.go diff --git a/client/project_member.go b/client/project_member.go new file mode 100644 index 00000000..b1ea7b75 --- /dev/null +++ b/client/project_member.go @@ -0,0 +1,138 @@ +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, "adding 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)) + } + tflog.Info(ctx, "adding project members", map[string]interface{}{ + "url": url, + }) + err := c.doRequest(clientRequest{ + ctx: ctx, + method: "PATCH", + url: url, + body: string(mustMarshal(request)), + }, 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, "adding 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/vercel/provider.go b/vercel/provider.go index 25d166be..0b8d2d19 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, + newProjectMemberResource, newProjectResource, newSharedEnvironmentVariableResource, newTeamConfigResource, diff --git a/vercel/resource_project.go b/vercel/resource_project.go index 4dda97cc..7361de52 100644 --- a/vercel/resource_project.go +++ b/vercel/resource_project.go @@ -1208,28 +1208,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..fc5ea62d --- /dev/null +++ b/vercel/resource_project_members.go @@ -0,0 +1,450 @@ +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/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, + 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, + 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, + 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 (m ProjectMembersModel) withNullInsteadOfUnknown(ctx context.Context) ProjectMembersModel { + members, _ := m.members(context.Background()) + var mavs []attr.Value + for i := 0; i < len(members); i++ { + m := members[i] + if m.UserID.IsUnknown() { + m.UserID = types.StringNull() + } + if m.Email.IsUnknown() { + m.Email = types.StringNull() + } + if m.Username.IsUnknown() { + m.Username = types.StringNull() + } + + mavs = append(mavs, types.ObjectValueMust( + memberAttrType.AttrTypes, + map[string]attr.Value{ + "user_id": m.UserID, + "email": m.Email, + "username": m.Username, + "role": m.Role, + }), + ) + } + + m.Members = types.SetValueMust(memberAttrType, mavs) + if m.TeamID.IsUnknown() { + m.TeamID = types.StringNull() + } + return m +} + +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 + } + + members, diags := plan.members(ctx) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + var requestMembers []client.ProjectMember + for _, m := range members { + 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 + } + + diags = resp.State.Set(ctx, plan.withNullInsteadOfUnknown(ctx)) + 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 stateHasMember(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 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 stateHasMember(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 + } + + // 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) + + // Remove members that are no longer in the plan + var remove []string + for _, current := range currentMembers { + if _, exists := plannedMemberMap[current.UID]; !exists { + err := r.client.RemoveProjectMember(ctx, client.ProjectMemberRequest{ + ProjectID: plan.ProjectID.ValueString(), + UserID: current.UID, + }) + if err != nil && !client.NotFound(err) { + resp.Diagnostics.AddError( + "Error removing Project Member", + fmt.Sprintf("Could not remove Project Member: %s", err), + ) + return + } + } + } + + // Add or update planned members + for _, planned := range plan.Members { + request := client.ProjectMemberRequest{ + ProjectID: plan.ProjectID.ValueString(), + Role: planned.Role.ValueString(), + } + + if !planned.UserID.IsNull() { + request.UserID = planned.UserID.ValueString() + } else if !planned.Email.IsNull() { + request.Email = planned.Email.ValueString() + } else { + request.Username = planned.Username.ValueString() + } + + // If member exists, update role if needed + if current, exists := currentMemberMap[request.UserID]; exists { + if current.Role != request.Role { + err := r.client.UpdateProjectMember(ctx, request) + if err != nil { + resp.Diagnostics.AddError( + "Error updating Project Member", + fmt.Sprintf("Could not update Project Member: %s", err), + ) + return + } + } + } else { + // Add new member + err := r.client.AddProjectMember(ctx, request) + if err != nil { + resp.Diagnostics.AddError( + "Error adding Project Member", + fmt.Sprintf("Could not add Project Member: %s", err), + ) + return + } + } + } + + diags := resp.State.Set(ctx, plan) + 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 + } + + // Remove all members + for _, member := range state.Members { + if member.UserID.IsNull() { + continue // Skip if no user ID (shouldn't happen in state) + } + + err := r.client.RemoveProjectMember(ctx, client.ProjectMemberRequest{ + ProjectID: state.ProjectID.ValueString(), + UserID: member.UserID.ValueString(), + }) + if err != nil && !client.NotFound(err) { + resp.Diagnostics.AddError( + "Error removing Project Member", + fmt.Sprintf("Could not remove Project Member: %s", err), + ) + return + } + } +}