From b58a7ea668edb748a6a823b682644c5f2fbe74ae Mon Sep 17 00:00:00 2001 From: Des Preston Date: Tue, 26 May 2026 20:19:17 +0000 Subject: [PATCH] feat: adopt existing team members by email --- docs/resources/team_member.md | 5 ++ launchdarkly/keys.go | 1 + .../resource_launchdarkly_team_member.go | 35 +++++++++++ .../resource_launchdarkly_team_member_test.go | 58 +++++++++++++++++++ 4 files changed, 99 insertions(+) diff --git a/docs/resources/team_member.md b/docs/resources/team_member.md index 63d57049..030aa96b 100644 --- a/docs/resources/team_member.md +++ b/docs/resources/team_member.md @@ -27,6 +27,10 @@ resource "launchdarkly_team_member" "example" { } ``` +To adopt an existing LaunchDarkly member with the same email address instead of failing creation, set `adopt_existing = true`. + +-> **Note:** When adopting an existing member, this resource updates mutable fields such as `role`, `custom_roles`, and `role_attributes`. LaunchDarkly does not allow admins to update a member's `first_name` or `last_name`. + ## Schema @@ -36,6 +40,7 @@ resource "launchdarkly_team_member" "example" { ### Optional +- `adopt_existing` (Boolean) If true, the provider adopts an existing LaunchDarkly team member with the same email address when creation fails because the member already exists. The provider updates mutable fields after adoption, but first and last name cannot be updated except by the team member. - `custom_roles` (Set of String) The list of custom roles keys associated with the team member. Custom roles are only available to customers on an Enterprise plan. To learn more, [read about our pricing](https://launchdarkly.com/pricing/). To upgrade your plan, [contact LaunchDarkly Sales](https://launchdarkly.com/contact-sales/). -> **Note:** each `launchdarkly_team_member` must have either a `role` or `custom_roles` argument. diff --git a/launchdarkly/keys.go b/launchdarkly/keys.go index e93f7716..ebf9c7da 100644 --- a/launchdarkly/keys.go +++ b/launchdarkly/keys.go @@ -6,6 +6,7 @@ const ( //gofmts:sort ACTIONS = "actions" ACTION_SET = "action_set" + ADOPT_EXISTING = "adopt_existing" AI_CONFIG_KEY = "config_key" ANALYSIS_TYPE = "analysis_type" API_KEY = "api_key" diff --git a/launchdarkly/resource_launchdarkly_team_member.go b/launchdarkly/resource_launchdarkly_team_member.go index 07f0c264..05d248c3 100644 --- a/launchdarkly/resource_launchdarkly_team_member.go +++ b/launchdarkly/resource_launchdarkly_team_member.go @@ -2,9 +2,11 @@ package launchdarkly import ( "context" + "encoding/json" "fmt" "log" "net/http" + "strings" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -37,6 +39,11 @@ func resourceTeamMember() *schema.Resource { ForceNew: true, Description: addForceNewDescription("The unique email address associated with the team member.", true), }, + ADOPT_EXISTING: { + Type: schema.TypeBool, + Optional: true, + Description: "If true, the provider adopts an existing LaunchDarkly team member with the same email address when creation fails because the member already exists. The provider updates mutable fields after adoption, but first and last name cannot be updated except by the team member.", + }, FIRST_NAME: { Type: schema.TypeString, Optional: true, @@ -105,6 +112,15 @@ func resourceTeamMemberCreate(ctx context.Context, d *schema.ResourceData, metaR return err }) if err != nil { + if d.Get(ADOPT_EXISTING).(bool) && isTeamMemberEmailAlreadyExistsError(err) { + member, err := getTeamMemberByEmail(client, memberEmail) + if err != nil { + return diag.Errorf("failed to adopt existing team member with email: %s: %v", memberEmail, err) + } + + d.SetId(member.Id) + return resourceTeamMemberUpdate(ctx, d, metaRaw) + } return diag.Errorf("failed to create team member with email: %s: %v", memberEmail, handleLdapiErr(err)) } @@ -229,3 +245,22 @@ func teamMemberExists(memberID string, client *Client) (bool, error) { return true, nil } + +const teamMemberEmailAlreadyExistsCode = "email_already_exists_in_account" + +func isTeamMemberEmailAlreadyExistsError(err error) bool { + if err == nil { + return false + } + + if openAPIErr, ok := err.(*ldapi.GenericOpenAPIError); ok { + var apiErr struct { + Code string `json:"code"` + } + if json.Unmarshal(openAPIErr.Body(), &apiErr) == nil && apiErr.Code == teamMemberEmailAlreadyExistsCode { + return true + } + } + + return strings.Contains(handleLdapiErr(err).Error(), teamMemberEmailAlreadyExistsCode) +} diff --git a/launchdarkly/resource_launchdarkly_team_member_test.go b/launchdarkly/resource_launchdarkly_team_member_test.go index 98c6bbcf..0e574796 100644 --- a/launchdarkly/resource_launchdarkly_team_member_test.go +++ b/launchdarkly/resource_launchdarkly_team_member_test.go @@ -2,11 +2,13 @@ package launchdarkly import ( "fmt" + "os" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/stretchr/testify/require" ) const ( @@ -27,6 +29,16 @@ resource "launchdarkly_team_member" "test" { role = "no_access" custom_roles = [] } +` + testAccTeamMemberAdoptExisting = ` +resource "launchdarkly_team_member" "test" { + email = "%s" + first_name = "Test" + last_name = "Account" + role = "no_access" + custom_roles = [] + adopt_existing = true +} ` testAccTeamMemberCustomRoleCreate = ` @@ -199,6 +211,52 @@ func TestAccTeamMember_CreateAndUpdateGeneric(t *testing.T) { }) } +func TestAccTeamMember_AdoptExisting(t *testing.T) { + if os.Getenv("TF_ACC") == "" { + t.SkipNow() + } + + randomName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + email := fmt.Sprintf("%s+wbteste2e@launchdarkly.com", randomName) + resourceName := "launchdarkly_team_member.test" + + client, err := newClient(os.Getenv(LAUNCHDARKLY_ACCESS_TOKEN), os.Getenv(LAUNCHDARKLY_API_HOST), false, DEFAULT_HTTP_TIMEOUT_S, DEFAULT_MAX_CONCURRENCY) + require.NoError(t, err) + + member, err := testAccDataSourceTeamMemberCreate(client, email) + require.NoError(t, err) + t.Cleanup(func() { + _ = testAccDataSourceTeamMemberDelete(client, member.Id) + }) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + Providers: testAccProviders, + CheckDestroy: testAccCheckTeamMemberDestroy, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(testAccTeamMemberAdoptExisting, email), + Check: resource.ComposeTestCheckFunc( + testAccCheckMemberExists(resourceName), + resource.TestCheckResourceAttr(resourceName, EMAIL, email), + resource.TestCheckResourceAttr(resourceName, FIRST_NAME, "Test"), + resource.TestCheckResourceAttr(resourceName, LAST_NAME, "Account"), + resource.TestCheckResourceAttr(resourceName, ROLE, "no_access"), + resource.TestCheckResourceAttr(resourceName, "custom_roles.#", "0"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ADOPT_EXISTING}, + }, + }, + }) +} + func TestAccTeamMember_WithCustomRole(t *testing.T) { roleKey1 := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) roleKey2 := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)