From 18775e1d950a854519882217170467fdc1acf30e Mon Sep 17 00:00:00 2001 From: codinja1188 <3358152+vasubabu@users.noreply.github.com> Date: Wed, 6 Mar 2024 20:30:44 +0530 Subject: [PATCH 1/3] refactor: migrate resource metal_organization_member to framework --- equinix/provider.go | 1 - equinix/resource_metal_organization_member.go | 285 ----------------- internal/provider/provider.go | 2 + .../metal/organization_member/models.go | 45 +++ .../metal/organization_member/resource.go | 297 ++++++++++++++++++ .../organization_member/resource_schema.go | 67 ++++ .../organization_member/resource_test.go | 53 ++-- 7 files changed, 441 insertions(+), 309 deletions(-) delete mode 100644 equinix/resource_metal_organization_member.go create mode 100644 internal/resources/metal/organization_member/models.go create mode 100644 internal/resources/metal/organization_member/resource.go create mode 100644 internal/resources/metal/organization_member/resource_schema.go rename equinix/resource_metal_organization_member_acc_test.go => internal/resources/metal/organization_member/resource_test.go (75%) diff --git a/equinix/provider.go b/equinix/provider.go index 45dc2f0b9..e1af82f75 100644 --- a/equinix/provider.go +++ b/equinix/provider.go @@ -133,7 +133,6 @@ func Provider() *schema.Provider { "equinix_metal_project_api_key": resourceMetalProjectAPIKey(), "equinix_metal_device": resourceMetalDevice(), "equinix_metal_device_network_type": resourceMetalDeviceNetworkType(), - "equinix_metal_organization_member": resourceMetalOrganizationMember(), "equinix_metal_port": resourceMetalPort(), "equinix_metal_project": metal_project.Resource(), "equinix_metal_reserved_ip_block": resourceMetalReservedIPBlock(), diff --git a/equinix/resource_metal_organization_member.go b/equinix/resource_metal_organization_member.go deleted file mode 100644 index 585a6ec44..000000000 --- a/equinix/resource_metal_organization_member.go +++ /dev/null @@ -1,285 +0,0 @@ -package equinix - -import ( - "fmt" - "log" - "path" - "strings" - - "github.com/equinix/terraform-provider-equinix/internal/converters" - - equinix_errors "github.com/equinix/terraform-provider-equinix/internal/errors" - equinix_schema "github.com/equinix/terraform-provider-equinix/internal/schema" - - "github.com/equinix/terraform-provider-equinix/internal/config" - - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" - "github.com/packethost/packngo" -) - -type member struct { - *packngo.Member - *packngo.Invitation -} - -func (m *member) isMember() bool { - return m.Member != nil -} - -func (m *member) isInvitation() bool { - return m.Invitation != nil -} - -func resourceMetalOrganizationMember() *schema.Resource { - return &schema.Resource{ - Create: resourceMetalOrganizationMemberCreate, - Read: resourceMetalOrganizationMemberRead, - Delete: resourceMetalOrganizationMemberDelete, - Importer: &schema.ResourceImporter{ - State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { - parts := strings.Split(d.Id(), ":") - invitee := parts[0] - orgID := parts[1] - d.SetId(d.Id()) - d.Set("invitee", invitee) - d.Set("organization_id", orgID) - if err := resourceMetalOrganizationMemberRead(d, meta); err != nil { - return nil, err - } - if d.Id() == "" { - return nil, fmt.Errorf("Member %s does not exist in organization %s.", invitee, orgID) - } - return []*schema.ResourceData{d}, nil - }, - }, - - Schema: map[string]*schema.Schema{ - "invitee": { - Type: schema.TypeString, - Description: "The email address of the user to invite", - Required: true, - ForceNew: true, - ValidateFunc: validation.StringIsNotEmpty, - }, - "invited_by": { - Type: schema.TypeString, - Description: "The user id of the user that sent the invitation (only known in the invitation stage)", - Computed: true, - }, - "organization_id": { - Type: schema.TypeString, - Description: "The organization to invite the user to", - Required: true, - ForceNew: true, - ValidateFunc: validation.StringIsNotEmpty, - }, - "projects_ids": { - Type: schema.TypeSet, - Description: "Project IDs the member has access to within the organization. If the member is an 'owner', the projects list should be empty.", - Required: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - ValidateFunc: validation.StringIsNotEmpty, - }, - // TODO: Update should be supported. packngo.InvitationService does not offer an Update method. - ForceNew: true, - }, - "nonce": { - Type: schema.TypeString, - Description: "The nonce for the invitation (only known in the invitation stage)", - Computed: true, - }, - "message": { - Type: schema.TypeString, - Description: "A message to the invitee (only used during the invitation stage)", - Optional: true, - ForceNew: true, - }, - "created": { - Type: schema.TypeString, - Description: "When the invitation was created (only known in the invitation stage)", - Computed: true, - }, - "updated": { - Type: schema.TypeString, - Description: "When the invitation was updated (only known in the invitation stage)", - Computed: true, - }, - "roles": { - Type: schema.TypeSet, - Description: "Organization roles (owner, collaborator, limited_collaborator, billing)", - Required: true, - Elem: &schema.Schema{Type: schema.TypeString}, - // TODO: Update should be supported. packngo.InvitationService does not offer an Update method. - ForceNew: true, - }, - "state": { - Type: schema.TypeString, - Description: "The state of the membership ('invited' when an invitation is open, 'active' when the user is an organization member)", - Computed: true, - }, - }, - } -} - -func resourceMetalOrganizationMemberCreate(d *schema.ResourceData, meta interface{}) error { - client := meta.(*config.Config).Metal - - email := d.Get("invitee").(string) - createRequest := &packngo.InvitationCreateRequest{ - Invitee: email, - Roles: converters.IfArrToStringArr(d.Get("roles").(*schema.Set).List()), - ProjectsIDs: converters.IfArrToStringArr(d.Get("projects_ids").(*schema.Set).List()), - Message: strings.TrimSpace(d.Get("message").(string)), - } - - orgID := d.Get("organization_id").(string) - _, _, err := client.Invitations.Create(orgID, createRequest, nil) - if err != nil { - return equinix_errors.FriendlyError(err) - } - - d.SetId(fmt.Sprintf("%s:%s", email, orgID)) - - return resourceMetalOrganizationMemberRead(d, meta) -} - -func findMember(invitee string, members []packngo.Member, invitations []packngo.Invitation) (*member, error) { - for _, mbr := range members { - if mbr.User.Email == invitee { - return &member{Member: &mbr}, nil - } - } - - for _, inv := range invitations { - if inv.Invitee == invitee { - return &member{Invitation: &inv}, nil - } - } - return nil, fmt.Errorf("member not found") -} - -func resourceMetalOrganizationMemberRead(d *schema.ResourceData, meta interface{}) error { - client := meta.(*config.Config).Metal - parts := strings.Split(d.Id(), ":") - invitee := parts[0] - orgID := parts[1] - - listOpts := &packngo.ListOptions{Includes: []string{"user"}} - invitations, _, err := client.Invitations.List(orgID, listOpts) - if err != nil { - err = equinix_errors.FriendlyError(err) - // If the org was destroyed, mark as gone. - if equinix_errors.IsNotFound(err) { - d.SetId("") - return nil - } - return err - } - - members, _, err := client.Members.List(orgID, &packngo.GetOptions{Includes: []string{"user"}}) - if err != nil { - err = equinix_errors.FriendlyError(err) - // If the org was destroyed, mark as gone. - if equinix_errors.IsNotFound(err) { - d.SetId("") - return nil - } - return err - } - member, err := findMember(invitee, members, invitations) - if !d.IsNewResource() && err != nil { - log.Printf("[WARN] Could not find member %s in organization, removing from state", d.Id()) - d.SetId("") - return nil - } - - if member.isMember() { - projectIDs := []string{} - for _, project := range member.Member.Projects { - projectIDs = append(projectIDs, path.Base(project.URL)) - } - return equinix_schema.SetMap(d, map[string]interface{}{ - "state": "active", - "roles": converters.StringArrToIfArr(member.Member.Roles), - "projects_ids": converters.StringArrToIfArr(projectIDs), - "organization_id": path.Base(member.Member.Organization.URL), - }) - } else if member.isInvitation() { - projectIDs := []string{} - for _, project := range member.Invitation.Projects { - projectIDs = append(projectIDs, path.Base(project.Href)) - } - return equinix_schema.SetMap(d, map[string]interface{}{ - "state": "invited", - "organization_id": path.Base(member.Invitation.Organization.Href), - "roles": member.Invitation.Roles, - "projects_ids": projectIDs, - "created": member.Invitation.CreatedAt.String(), - "updated": member.Invitation.UpdatedAt.String(), - "nonce": member.Invitation.Nonce, - "invited_by": path.Base(member.Invitation.InvitedBy.Href), - }) - } - return fmt.Errorf("got an invalid member object") -} - -func resourceMetalOrganizationMemberDelete(d *schema.ResourceData, meta interface{}) error { - client := meta.(*config.Config).Metal - - listOpts := &packngo.ListOptions{Includes: []string{"user"}} - invitations, _, err := client.Invitations.List(d.Get("organization_id").(string), listOpts) - if err != nil { - err = equinix_errors.FriendlyError(err) - // If the org was destroyed, mark as gone. - if equinix_errors.IsNotFound(err) { - d.SetId("") - return nil - } - return err - } - - orgID := d.Get("organization_id").(string) - org, _, err := client.Organizations.Get(orgID, &packngo.GetOptions{Includes: []string{"members", "members.user"}}) - if err != nil { - err = equinix_errors.FriendlyError(err) - // If the org was destroyed, mark as gone. - if equinix_errors.IsNotFound(err) { - d.SetId("") - return nil - } - return err - } - - member, err := findMember(d.Get("invitee").(string), org.Members, invitations) - if err != nil { - d.SetId("") - return nil - } - - if member.isMember() { - _, err = client.Members.Delete(orgID, member.Member.ID) - if err != nil { - err = equinix_errors.FriendlyError(err) - // If the member was deleted, mark as gone. - if equinix_errors.IsNotFound(err) { - d.SetId("") - return nil - } - return err - } - } else if member.isInvitation() { - _, err = client.Invitations.Delete(member.Invitation.ID) - if err != nil { - err = equinix_errors.FriendlyError(err) - // If the invitation was deleted, mark as gone. - if equinix_errors.IsNotFound(err) { - d.SetId("") - return nil - } - return err - } - } - return nil -} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index e682f63c3..f5677c607 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -8,6 +8,7 @@ import ( metalconnection "github.com/equinix/terraform-provider-equinix/internal/resources/metal/connection" metalgateway "github.com/equinix/terraform-provider-equinix/internal/resources/metal/gateway" metalorganization "github.com/equinix/terraform-provider-equinix/internal/resources/metal/organization" + metalorganizationmember "github.com/equinix/terraform-provider-equinix/internal/resources/metal/organization_member" metalprojectsshkey "github.com/equinix/terraform-provider-equinix/internal/resources/metal/project_ssh_key" metalsshkey "github.com/equinix/terraform-provider-equinix/internal/resources/metal/ssh_key" equinix_validation "github.com/equinix/terraform-provider-equinix/internal/validation" @@ -116,6 +117,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res metalsshkey.NewResource, metalconnection.NewResource, metalorganization.NewResource, + metalorganizationmember.NewResource, } } diff --git a/internal/resources/metal/organization_member/models.go b/internal/resources/metal/organization_member/models.go new file mode 100644 index 000000000..e220bbfcd --- /dev/null +++ b/internal/resources/metal/organization_member/models.go @@ -0,0 +1,45 @@ +package organizationmember + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + + "github.com/packethost/packngo" +) + +type ResourceModel struct { + ID types.String `tfsdk:"id"` + Invitee types.String `tfsdk:"invitee"` + InvitedBy types.String `tfsdk:"invited_by"` + OrganizationID types.String `tfsdk:"organization_id"` + ProjectsIDs types.Set `tfsdk:"projects_ids"` + Nonce types.String `tfsdk:"nonce"` + Message types.String `tfsdk:"message"` + Created types.String `tfsdk:"created"` + Updated types.String `tfsdk:"updated"` + Roles types.Set `tfsdk:"roles"` + State types.String `tfsdk:"state"` +} + +func (m *ResourceModel) parse(ctx context.Context, org *packngo.Invitation) diag.Diagnostics { + var diags diag.Diagnostics + m.Invitee = types.StringValue(org.Invitee) + m.InvitedBy = types.StringValue(org.InvitedBy.Href) + m.OrganizationID = types.StringValue(org.ID) + + ProjectList, _ := types.SetValueFrom(ctx, types.StringType, org.Projects) + m.ProjectsIDs = ProjectList + + m.Nonce = types.StringValue(org.Nonce) + m.Created = types.StringValue(org.CreatedAt.String()) + m.Updated = types.StringValue(org.UpdatedAt.String()) + + rolesList, _ := types.SetValueFrom(ctx, types.StringType, org.Roles) + m.Roles = rolesList + m.State = types.StringValue("active") + + m.ID = types.StringValue(org.ID) + return diags +} diff --git a/internal/resources/metal/organization_member/resource.go b/internal/resources/metal/organization_member/resource.go new file mode 100644 index 000000000..5e2857676 --- /dev/null +++ b/internal/resources/metal/organization_member/resource.go @@ -0,0 +1,297 @@ +package organizationmember + +import ( + "context" + "fmt" + "log" + "path" + "strings" + + equinix_errors "github.com/equinix/terraform-provider-equinix/internal/errors" + "github.com/equinix/terraform-provider-equinix/internal/framework" + tfpath "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/packethost/packngo" +) + +type member struct { + *packngo.Member + *packngo.Invitation +} + +func (m *member) isMember() bool { + return m.Member != nil +} + +func (m *member) isInvitation() bool { + return m.Invitation != nil +} + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: framework.NewBaseResource( + framework.BaseResourceConfig{ + Name: "equinix_metal_organization_member", + Schema: GetResourceSchema(), + }, + ), + } +} + +type Resource struct { + framework.BaseResource +} + +func (r *Resource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + tflog.Debug(ctx, "importer Organization") + + parts := strings.Split(req.ID, ":") + if len(parts) != 2 { + return + + } + invitee := parts[0] + orgID := parts[1] + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, tfpath.Root("invitee"), invitee)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, tfpath.Root("organization_id"), orgID)...) +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + var plan ResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + email := plan.Invitee.ValueString() + + roles := make([]string, 0) + for _, elem := range plan.Roles.Elements() { + if strValue, ok := elem.(types.String); ok { + + if !strValue.IsNull() { + roles = append(roles, strValue.ValueString()) + } + } + } + projects := make([]string, 0) + for _, elem := range plan.ProjectsIDs.Elements() { + if strValue, ok := elem.(types.String); ok { + projects = append(projects, strValue.ValueString()) + } + } + createRequest := &packngo.InvitationCreateRequest{ + Invitee: email, + Roles: roles, + ProjectsIDs: projects, + Message: strings.TrimSpace(plan.Message.ValueString()), + } + + orgID := plan.OrganizationID.ValueString() + invitationRequest, _, err := client.Invitations.Create(orgID, createRequest, nil) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create Organizations", + equinix_errors.FriendlyError(err).Error(), + ) + return + } + + // Parse API response into the Terraform state + plan.parse(ctx, invitationRequest) + + // Set state to fully populated data + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + tflog.Debug(ctx, "Read Organization") + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + // Retrieve values from plan + var data ResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + invitee := data.Invitee.ValueString() + orgID := data.OrganizationID.ValueString() + + listOpts := &packngo.ListOptions{Includes: []string{"user"}} + invitations, _, err := client.Invitations.List(orgID, listOpts) + if err != nil { + err = equinix_errors.FriendlyError(err) + // If the org was destroyed, mark as gone. + if equinix_errors.IsNotFound(err) { + data.OrganizationID = basetypes.NewStringNull() + return + } + return + } + + members, _, err := client.Members.List(orgID, &packngo.GetOptions{Includes: []string{"user"}}) + if err != nil { + err = equinix_errors.FriendlyError(err) + // If the org was destroyed, mark as gone. + if equinix_errors.IsNotFound(err) { + data.OrganizationID = basetypes.NewStringNull() + return + } + return + } + member, err := findMember(invitee, members, invitations) + if err != nil { + log.Printf("[WARN] Could not find member %s in organization, removing from state", data.OrganizationID) + data.OrganizationID = basetypes.NewStringNull() + return + } + + if member.isMember() { + projectsList, diags := types.SetValueFrom(context.Background(), types.StringType, member.Member.Projects) + if diags.HasError() { + return + } + data.ProjectsIDs = projectsList + data.State = types.StringValue("active") + + rolesList, diags := types.SetValueFrom(context.Background(), types.StringType, member.Member.Roles) + if diags.HasError() { + return + } + data.Roles = rolesList + data.OrganizationID = types.StringValue(member.Member.Organization.URL) + + // data.Created = types.StringValue(member.CreatedAt.String()) + // data.Updated = types.StringValue(member.UpdatedAt.String()) + } else if member.isInvitation() { + projectsList, diags := types.SetValueFrom(context.Background(), types.StringType, member.Member.Projects) + if diags.HasError() { + return + } + data.ProjectsIDs = projectsList + data.State = types.StringValue("active") + + rolesList, diags := types.SetValueFrom(context.Background(), types.StringType, member.Member.Roles) + if diags.HasError() { + return + } + data.Roles = rolesList + data.OrganizationID = types.StringValue(member.Member.Organization.URL) + data.Created = types.StringValue(member.Invitation.CreatedAt.String()) + data.Updated = types.StringValue(member.Invitation.UpdatedAt.String()) + data.Nonce = types.StringValue(member.Invitation.Nonce) + data.InvitedBy = types.StringValue(path.Base(member.Invitation.InvitedBy.Href)) + } + + // Set state to fully populated data + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func findMember(invitee string, members []packngo.Member, invitations []packngo.Invitation) (*member, error) { + for _, mbr := range members { + if mbr.User.Email == invitee { + return &member{Member: &mbr}, nil + } + } + + for _, inv := range invitations { + if inv.Invitee == invitee { + return &member{Invitation: &inv}, nil + } + } + return nil, fmt.Errorf("member not found") +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + tflog.Debug(ctx, "Delete Organization") + r.Meta.AddFwModuleToMetalUserAgent(ctx, req.ProviderMeta) + client := r.Meta.Metal + + var data ResourceModel + + listOpts := &packngo.ListOptions{Includes: []string{"user"}} + invitations, _, err := client.Invitations.List(data.OrganizationID.ValueString(), listOpts) + if err != nil { + err = equinix_errors.FriendlyError(err) + // If the org was destroyed, mark as gone. + if equinix_errors.IsNotFound(err) { + data.OrganizationID = types.StringNull() + return + } + return + } + + org, _, err := client.Organizations.Get(data.OrganizationID.ValueString(), &packngo.GetOptions{Includes: []string{"members", "members.user"}}) + if err != nil { + err = equinix_errors.FriendlyError(err) + // If the org was destroyed, mark as gone. + if equinix_errors.IsNotFound(err) { + data.OrganizationID = types.StringNull() + return + } + return + } + + member, err := findMember(data.Invitee.ValueString(), org.Members, invitations) + if err != nil { + data.OrganizationID = types.StringNull() + return + } + + if member.isMember() { + _, err = client.Members.Delete(data.OrganizationID.ValueString(), member.Member.ID) + if err != nil { + err = equinix_errors.FriendlyError(err) + // If the member was deleted, mark as gone. + if equinix_errors.IsNotFound(err) { + data.OrganizationID = types.StringNull() + return + } + return + } + } else if member.isInvitation() { + _, err = client.Invitations.Delete(member.Invitation.ID) + if err != nil { + err = equinix_errors.FriendlyError(err) + // If the invitation was deleted, mark as gone. + if equinix_errors.IsNotFound(err) { + data.OrganizationID = types.StringNull() + return + } + return + } + } +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + // This resource does not support updates +} diff --git a/internal/resources/metal/organization_member/resource_schema.go b/internal/resources/metal/organization_member/resource_schema.go new file mode 100644 index 000000000..7808020bd --- /dev/null +++ b/internal/resources/metal/organization_member/resource_schema.go @@ -0,0 +1,67 @@ +package organizationmember + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func GetResourceSchema() *schema.Schema { + return &schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for the organization member.", + Computed: true, + }, + "invitee": schema.StringAttribute{ + Description: "The email address of the user to invite", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "invited_by": schema.StringAttribute{ + Description: "The user id of the user that sent the invitation (only known in the invitation stage)", + Computed: true, + }, + "organization_id": schema.StringAttribute{ + Description: "The organization to invite the user to", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "projects_ids": schema.SetAttribute{ + Description: "Project IDs the member has access to within the organization. If the member is an 'owner', the projects list should be empty.", + Required: true, + ElementType: types.StringType, + }, + "nonce": schema.StringAttribute{ + Description: "The nonce for the invitation (only known in the invitation stage)", + Computed: true, + }, + "message": schema.StringAttribute{ + Description: "A message to the invitee (only used during the invitation stage)", + Optional: true, + }, + "created": schema.StringAttribute{ + Description: "When the invitation was created (only known in the invitation stage)", + Computed: true, + }, + "updated": schema.StringAttribute{ + Description: "When the invitation was updated (only known in the invitation stage)", + Computed: true, + }, + "roles": schema.SetAttribute{ + ElementType: types.StringType, + Description: "Organization roles (owner, collaborator, limited_collaborator, billing)", + Required: true, + }, + "state": schema.StringAttribute{ + Description: "The state of the membership ('invited' when an invitation is open, 'active' when the user is an organization member)", + Computed: true, + }, + }, + } +} diff --git a/equinix/resource_metal_organization_member_acc_test.go b/internal/resources/metal/organization_member/resource_test.go similarity index 75% rename from equinix/resource_metal_organization_member_acc_test.go rename to internal/resources/metal/organization_member/resource_test.go index 9e201437c..98a466bcd 100644 --- a/equinix/resource_metal_organization_member_acc_test.go +++ b/internal/resources/metal/organization_member/resource_test.go @@ -1,9 +1,10 @@ -package equinix +package organizationmember_test import ( "fmt" "testing" + "github.com/equinix/terraform-provider-equinix/internal/acceptance" "github.com/equinix/terraform-provider-equinix/internal/config" "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -15,11 +16,9 @@ func TestAccResourceMetalOrganizationMember_owner(t *testing.T) { rInt := acctest.RandInt() org := &packngo.Organization{} resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ExternalProviders: testExternalProviders, - ProtoV5ProviderFactories: testAccProtoV5ProviderFactories, - // TODO: CheckDestroy: testAccMetalOrganizationMemberCheckDestroyed, - CheckDestroy: nil, + PreCheck: func() { acceptance.TestAccPreCheckMetal(t); acceptance.TestAccPreCheckProviderConfigured(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: testAccMetalOrganizationCheckDestroyed, Steps: []resource.TestStep{ { Config: testAccResourceMetalOrganizationMember_basic(rInt), @@ -35,13 +34,6 @@ func TestAccResourceMetalOrganizationMember_owner(t *testing.T) { }), ImportState: true, }, - /* - { - ResourceName: "equinix_metal_organization_member.owner", - Config: testAccResourceMetalOrganizationMember_basic(rInt) + testAccResourceMetalOrganizationMember_owner(), - ExpectError: regexp.MustCompile("User is already a member of the Organization"), - }, - */ { Config: testAccResourceMetalOrganizationMember_basic(rInt), }, @@ -53,11 +45,9 @@ func TestAccResourceMetalOrganizationMember_basic(t *testing.T) { rInt := acctest.RandInt() org := &packngo.Organization{} resource.ParallelTest(t, resource.TestCase{ - PreCheck: func() { testAccPreCheck(t) }, - ExternalProviders: testExternalProviders, - ProtoV5ProviderFactories: testAccProtoV5ProviderFactories, - // TODO: CheckDestroy: testAccMetalOrganizationMemberCheckDestroyed, - CheckDestroy: nil, + PreCheck: func() { acceptance.TestAccPreCheckMetal(t); acceptance.TestAccPreCheckProviderConfigured(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: nil, Steps: []resource.TestStep{ { Config: testAccResourceMetalOrganizationMember_basic(rInt), @@ -104,21 +94,21 @@ resource "equinix_metal_project" "test" { } func testAccResourceMetalOrganizationMember_owner() string { - return fmt.Sprintf(` + return ` resource "equinix_metal_organization_member" "owner" { - invitee = "/* TODO: Add org owner email or token owner email here */" + invitee = "tfacc.testing.member@equinixmetal.com" roles = ["owner"] projects_ids = [] organization_id = equinix_metal_organization.test.id } - `) + ` } func testAccResourceMetalOrganizationMember_member() string { return ` resource "equinix_metal_organization_member" "member" { invitee = "tfacc.testing.member@equinixmetal.com" - roles = ["limited_collaborator"] + roles = ["limited_collaborator"] projects_ids = [equinix_metal_project.test.id] organization_id = equinix_metal_organization.test.id message = "This invitation was sent by the github.com/equinix/terraform-provider-equinix acceptance tests to test equinix_metal_organization_member resources." @@ -126,6 +116,23 @@ resource "equinix_metal_organization_member" "member" { ` } +// invitee = "/* TODO: Add org owner email or token owner email here */" +// invitee = "tfacc.testing.member@equinixmetal.com" +func testAccMetalOrganizationCheckDestroyed(s *terraform.State) error { + client := acceptance.TestAccProvider.Meta().(*config.Config).Metal + + for _, rs := range s.RootModule().Resources { + if rs.Type != "equinix_metal_organization" { + continue + } + if _, _, err := client.Organizations.Get(rs.Primary.ID, nil); err == nil { + return fmt.Errorf("Metal Organization still exists") + } + } + + return nil +} + func testAccMetalOrganizationExists(n string, org *packngo.Organization) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] @@ -136,7 +143,7 @@ func testAccMetalOrganizationExists(n string, org *packngo.Organization) resourc return fmt.Errorf("No Record ID is set") } - client := testAccProvider.Meta().(*config.Config).Metal + client := acceptance.TestAccProvider.Meta().(*config.Config).Metal foundOrg, _, err := client.Organizations.Get(rs.Primary.ID, &packngo.GetOptions{Includes: []string{"address", "primary_owner"}}) if err != nil { From 8762d89195c3a5f93beba9354022635b09d0f575 Mon Sep 17 00:00:00 2001 From: codinja1188 <3358152+vasubabu@users.noreply.github.com> Date: Thu, 14 Mar 2024 21:46:49 +0530 Subject: [PATCH 2/3] refactor: improved the code --- .../metal/organization_member/models.go | 58 +++++-- .../metal/organization_member/resource.go | 158 +++++++++++------- .../organization_member/resource_test.go | 6 +- 3 files changed, 144 insertions(+), 78 deletions(-) diff --git a/internal/resources/metal/organization_member/models.go b/internal/resources/metal/organization_member/models.go index e220bbfcd..a09e6ec03 100644 --- a/internal/resources/metal/organization_member/models.go +++ b/internal/resources/metal/organization_member/models.go @@ -2,11 +2,10 @@ package organizationmember import ( "context" + "path" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - - "github.com/packethost/packngo" ) type ResourceModel struct { @@ -23,23 +22,50 @@ type ResourceModel struct { State types.String `tfsdk:"state"` } -func (m *ResourceModel) parse(ctx context.Context, org *packngo.Invitation) diag.Diagnostics { +// func (m *ResourceModel) parse(ctx context.Context, invitee string, members []packngo.Member, invitations []packngo.Invitation) diag.Diagnostics { +func (m *ResourceModel) parse(ctx context.Context, member *member) diag.Diagnostics { var diags diag.Diagnostics - m.Invitee = types.StringValue(org.Invitee) - m.InvitedBy = types.StringValue(org.InvitedBy.Href) - m.OrganizationID = types.StringValue(org.ID) - ProjectList, _ := types.SetValueFrom(ctx, types.StringType, org.Projects) - m.ProjectsIDs = ProjectList + if member.isMember() { + projectsList, diag := types.SetValueFrom(ctx, types.StringType, member.Member.Projects) + if diag.HasError() { + return diag + } + m.ProjectsIDs = projectsList + m.State = types.StringValue("active") + + rolesList, diag := types.SetValueFrom(ctx, types.StringType, member.Member.Roles) + if diag.HasError() { + return diag + } + m.Roles = rolesList + m.OrganizationID = types.StringValue(member.Member.Organization.URL) + + } else if member.isInvitation() { + + projectsList, diag := types.SetValueFrom(ctx, types.StringType, member.Invitation.Projects) + if diag.HasError() { + return diag + } + m.ProjectsIDs = projectsList + + m.State = types.StringValue("invited") - m.Nonce = types.StringValue(org.Nonce) - m.Created = types.StringValue(org.CreatedAt.String()) - m.Updated = types.StringValue(org.UpdatedAt.String()) + rolesList, diag := types.SetValueFrom(ctx, types.StringType, member.Invitation.Roles) + if diag.HasError() { + return diag + } + m.Roles = rolesList - rolesList, _ := types.SetValueFrom(ctx, types.StringType, org.Roles) - m.Roles = rolesList - m.State = types.StringValue("active") + //m.OrganizationID = types.StringValue(member.Invitation.Organization.Href) + m.OrganizationID = types.StringValue(path.Base(member.Invitation.Organization.Href)) + m.Created = types.StringValue(member.Invitation.CreatedAt.String()) + m.Updated = types.StringValue(member.Invitation.UpdatedAt.String()) + m.Nonce = types.StringValue(member.Invitation.Nonce) - m.ID = types.StringValue(org.ID) + //m.InvitedBy = types.StringValue(member.Invitation.InvitedBy.Href) + m.InvitedBy = types.StringValue(path.Base(member.Invitation.InvitedBy.Href)) + m.ID = types.StringValue(member.Invitation.ID) + } return diags } diff --git a/internal/resources/metal/organization_member/resource.go b/internal/resources/metal/organization_member/resource.go index 5e2857676..7b7c7ec88 100644 --- a/internal/resources/metal/organization_member/resource.go +++ b/internal/resources/metal/organization_member/resource.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "path" "strings" equinix_errors "github.com/equinix/terraform-provider-equinix/internal/errors" @@ -78,7 +77,7 @@ func (r *Resource) Create( return } - email := plan.Invitee.ValueString() + invite := plan.Invitee.ValueString() roles := make([]string, 0) for _, elem := range plan.Roles.Elements() { @@ -89,31 +88,80 @@ func (r *Resource) Create( } } } + projects := make([]string, 0) for _, elem := range plan.ProjectsIDs.Elements() { if strValue, ok := elem.(types.String); ok { projects = append(projects, strValue.ValueString()) } } + createRequest := &packngo.InvitationCreateRequest{ - Invitee: email, + Invitee: invite, Roles: roles, ProjectsIDs: projects, Message: strings.TrimSpace(plan.Message.ValueString()), } orgID := plan.OrganizationID.ValueString() - invitationRequest, _, err := client.Invitations.Create(orgID, createRequest, nil) + _, _, err := client.Invitations.Create(orgID, createRequest, nil) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create invitation", + err.Error(), + ) + return + } + + listOpts := &packngo.ListOptions{Includes: []string{"user"}} + invitations, _, err := client.Invitations.List(orgID, listOpts) + if err != nil { + err = equinix_errors.FriendlyError(err) + // If the org was destroyed, mark as gone. + if equinix_errors.IsNotFound(err) { + plan.OrganizationID = basetypes.NewStringNull() + return + } + resp.Diagnostics.AddError( + "Failed to list invitations", + err.Error(), + ) + return + } + + members, _, err := client.Members.List(orgID, listOpts) + if err != nil { + err = equinix_errors.FriendlyError(err) + // If the org was destroyed, mark as gone. + if equinix_errors.IsNotFound(err) { + return + } + resp.Diagnostics.AddError( + "Failed to List members", + err.Error(), + ) + return + } + + member, err := findMember(invite, members, invitations) if err != nil { + log.Printf("[WARN] Could not find member %s in organization, removing from state", plan.OrganizationID) + plan.OrganizationID = basetypes.NewStringNull() resp.Diagnostics.AddError( - "Failed to create Organizations", - equinix_errors.FriendlyError(err).Error(), + "Failed to find members", + err.Error(), ) return } // Parse API response into the Terraform state - plan.parse(ctx, invitationRequest) + if member != nil { + diags := plan.parse(ctx, member) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + } // Set state to fully populated data resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) @@ -147,6 +195,10 @@ func (r *Resource) Read( data.OrganizationID = basetypes.NewStringNull() return } + resp.Diagnostics.AddError( + "Failed to list invitations", + err.Error(), + ) return } @@ -158,6 +210,10 @@ func (r *Resource) Read( data.OrganizationID = basetypes.NewStringNull() return } + resp.Diagnostics.AddError( + "Failed to list members", + err.Error(), + ) return } member, err := findMember(invitee, members, invitations) @@ -167,62 +223,15 @@ func (r *Resource) Read( return } - if member.isMember() { - projectsList, diags := types.SetValueFrom(context.Background(), types.StringType, member.Member.Projects) - if diags.HasError() { - return - } - data.ProjectsIDs = projectsList - data.State = types.StringValue("active") - - rolesList, diags := types.SetValueFrom(context.Background(), types.StringType, member.Member.Roles) - if diags.HasError() { - return - } - data.Roles = rolesList - data.OrganizationID = types.StringValue(member.Member.Organization.URL) - - // data.Created = types.StringValue(member.CreatedAt.String()) - // data.Updated = types.StringValue(member.UpdatedAt.String()) - } else if member.isInvitation() { - projectsList, diags := types.SetValueFrom(context.Background(), types.StringType, member.Member.Projects) - if diags.HasError() { - return - } - data.ProjectsIDs = projectsList - data.State = types.StringValue("active") - - rolesList, diags := types.SetValueFrom(context.Background(), types.StringType, member.Member.Roles) - if diags.HasError() { - return - } - data.Roles = rolesList - data.OrganizationID = types.StringValue(member.Member.Organization.URL) - data.Created = types.StringValue(member.Invitation.CreatedAt.String()) - data.Updated = types.StringValue(member.Invitation.UpdatedAt.String()) - data.Nonce = types.StringValue(member.Invitation.Nonce) - data.InvitedBy = types.StringValue(path.Base(member.Invitation.InvitedBy.Href)) + diags := data.parse(ctx, member) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return } - // Set state to fully populated data resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } -func findMember(invitee string, members []packngo.Member, invitations []packngo.Invitation) (*member, error) { - for _, mbr := range members { - if mbr.User.Email == invitee { - return &member{Member: &mbr}, nil - } - } - - for _, inv := range invitations { - if inv.Invitee == invitee { - return &member{Invitation: &inv}, nil - } - } - return nil, fmt.Errorf("member not found") -} - func (r *Resource) Delete( ctx context.Context, req resource.DeleteRequest, @@ -243,6 +252,10 @@ func (r *Resource) Delete( data.OrganizationID = types.StringNull() return } + resp.Diagnostics.AddError( + "Failed to list invitations", + err.Error(), + ) return } @@ -254,6 +267,11 @@ func (r *Resource) Delete( data.OrganizationID = types.StringNull() return } + + resp.Diagnostics.AddError( + "Failed to get Organizations", + err.Error(), + ) return } @@ -272,6 +290,10 @@ func (r *Resource) Delete( data.OrganizationID = types.StringNull() return } + resp.Diagnostics.AddError( + "Failed to delete member", + err.Error(), + ) return } } else if member.isInvitation() { @@ -283,6 +305,11 @@ func (r *Resource) Delete( data.OrganizationID = types.StringNull() return } + + resp.Diagnostics.AddError( + "Failed to delete invitation", + err.Error(), + ) return } } @@ -295,3 +322,18 @@ func (r *Resource) Update( ) { // This resource does not support updates } + +func findMember(invitee string, members []packngo.Member, invitations []packngo.Invitation) (*member, error) { + for _, mbr := range members { + if mbr.User.Email == invitee { + return &member{Member: &mbr}, nil + } + } + + for _, inv := range invitations { + if inv.Invitee == invitee { + return &member{Invitation: &inv}, nil + } + } + return nil, fmt.Errorf("member not found") +} diff --git a/internal/resources/metal/organization_member/resource_test.go b/internal/resources/metal/organization_member/resource_test.go index 98a466bcd..41f53942d 100644 --- a/internal/resources/metal/organization_member/resource_test.go +++ b/internal/resources/metal/organization_member/resource_test.go @@ -47,7 +47,7 @@ func TestAccResourceMetalOrganizationMember_basic(t *testing.T) { resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { acceptance.TestAccPreCheckMetal(t); acceptance.TestAccPreCheckProviderConfigured(t) }, ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, - CheckDestroy: nil, + CheckDestroy: testAccMetalOrganizationCheckDestroyed, Steps: []resource.TestStep{ { Config: testAccResourceMetalOrganizationMember_basic(rInt), @@ -96,7 +96,7 @@ resource "equinix_metal_project" "test" { func testAccResourceMetalOrganizationMember_owner() string { return ` resource "equinix_metal_organization_member" "owner" { - invitee = "tfacc.testing.member@equinixmetal.com" + invitee = "/* TODO: Add org owner email or token owner email here */" roles = ["owner"] projects_ids = [] organization_id = equinix_metal_organization.test.id @@ -116,8 +116,6 @@ resource "equinix_metal_organization_member" "member" { ` } -// invitee = "/* TODO: Add org owner email or token owner email here */" -// invitee = "tfacc.testing.member@equinixmetal.com" func testAccMetalOrganizationCheckDestroyed(s *terraform.State) error { client := acceptance.TestAccProvider.Meta().(*config.Config).Metal From 5a947d4438bb8c5200a2009d4ed5fb09756decd5 Mon Sep 17 00:00:00 2001 From: Charles Treatman <ctreatman@equinix.com> Date: Tue, 19 Mar 2024 14:36:02 -0500 Subject: [PATCH 3/3] convert invitation/member projects to lists of project IDs --- .../metal/organization_member/models.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/internal/resources/metal/organization_member/models.go b/internal/resources/metal/organization_member/models.go index a09e6ec03..9a9b16ce8 100644 --- a/internal/resources/metal/organization_member/models.go +++ b/internal/resources/metal/organization_member/models.go @@ -27,7 +27,12 @@ func (m *ResourceModel) parse(ctx context.Context, member *member) diag.Diagnost var diags diag.Diagnostics if member.isMember() { - projectsList, diag := types.SetValueFrom(ctx, types.StringType, member.Member.Projects) + projectIDs := []string{} + for _, project := range member.Member.Projects { + projectIDs = append(projectIDs, path.Base(project.URL)) + } + + projectsList, diag := types.SetValueFrom(ctx, types.StringType, projectIDs) if diag.HasError() { return diag } @@ -42,8 +47,11 @@ func (m *ResourceModel) parse(ctx context.Context, member *member) diag.Diagnost m.OrganizationID = types.StringValue(member.Member.Organization.URL) } else if member.isInvitation() { - - projectsList, diag := types.SetValueFrom(ctx, types.StringType, member.Invitation.Projects) + projectIDs := []string{} + for _, project := range member.Invitation.Projects { + projectIDs = append(projectIDs, path.Base(project.Href)) + } + projectsList, diag := types.SetValueFrom(ctx, types.StringType, projectIDs) if diag.HasError() { return diag } @@ -57,13 +65,11 @@ func (m *ResourceModel) parse(ctx context.Context, member *member) diag.Diagnost } m.Roles = rolesList - //m.OrganizationID = types.StringValue(member.Invitation.Organization.Href) m.OrganizationID = types.StringValue(path.Base(member.Invitation.Organization.Href)) m.Created = types.StringValue(member.Invitation.CreatedAt.String()) m.Updated = types.StringValue(member.Invitation.UpdatedAt.String()) m.Nonce = types.StringValue(member.Invitation.Nonce) - //m.InvitedBy = types.StringValue(member.Invitation.InvitedBy.Href) m.InvitedBy = types.StringValue(path.Base(member.Invitation.InvitedBy.Href)) m.ID = types.StringValue(member.Invitation.ID) }