From 9d760fc9c0bca9885ce402e0a609fa68599f58b7 Mon Sep 17 00:00:00 2001 From: Richard Lee Date: Sat, 20 Aug 2022 07:16:26 -0700 Subject: [PATCH] Adopt existing channel on create (#113) * Adopt existing channel on create If a new 'adopt_existing_channel' attribute is set 'true' (default 'false' for backwards compatibility), an existing channel with the same name and type will be 'adopted' by the terraform provider, and unarchived if necessary, to serve as the newly 'created' channel. This brings parity with the existing provider functionality of archiving or abandoning channels on terraform resource destroy. Now you can do a 'terraform apply', 'terraform destroy', and 'terraform apply' again without errors. * Make sure api user is in the channel before doing channel membership operations (needed when adopting existing unarchived channels) * Update to terraform-plugin-log v0.4.0 The upstream provider updated the sdk version, which pulled in this new version. The new version has breaking API changes to logging to make it more 'structured'. * Update documentation on conversation api. * Update go.mod and go.sum for new deps * Update go.mod to version 0.7.0 of terraform-plugin-log * added newline * Made changes to satisfy pedantic linter. * Address pedantic docs linter whitespace issues. * Fix test failures - some channel types (notably private ones) can't be joined. - ephemeral attributes shouldn't be checked --- docs/resources/conversation.md | 33 +++++++++++- go.mod | 1 + go.sum | 9 ++++ slack/resource_conversation.go | 83 ++++++++++++++++++++++++++++- slack/resource_conversation_test.go | 2 +- 5 files changed, 125 insertions(+), 3 deletions(-) diff --git a/docs/resources/conversation.md b/docs/resources/conversation.md index c9ac368..d37846f 100644 --- a/docs/resources/conversation.md +++ b/docs/resources/conversation.md @@ -11,14 +11,30 @@ Manages a Slack channel This resource requires the following scopes: +If using `bot` tokens: + +- [channels:read](https://api.slack.com/scopes/channels:read) +(public channels) +- [channels:manage](https://api.slack.com/scopes/channels:manage) +(public channels) +- [channels:join](https://api.slack.com/scopes/channels:join) +(adopting existing public channels) +- [groups:read](https://api.slack.com/scopes/groups:read) +(private channels) +- [groups:write](https://api.slack.com/scopes/groups:write) +(private channels) + +If using `user` tokens: + - [channels:read](https://api.slack.com/scopes/channels:read) (public channels) -- [channels:manage](https://api.slack.com/scopes/channels:manage) (public channels) +- [channels:write](https://api.slack.com/scopes/channels:manage) (public channels) - [groups:read](https://api.slack.com/scopes/groups:read) (private channels) - [groups:write](https://api.slack.com/scopes/groups:write) (private channels) The Slack API methods used by the resource are: - [conversations.create](https://api.slack.com/methods/conversations.create) +- [conversations.join](https://api.slack.com/methods/conversations.join) - [conversations.setTopic](https://api.slack.com/methods/conversations.setTopic) - [conversations.setPurpose](https://api.slack.com/methods/conversations.setPurpose) - [conversations.info](https://api.slack.com/methods/conversations.info) @@ -53,6 +69,16 @@ resource "slack_conversation" "nonadmin" { } ``` +```hcl +resource "slack_conversation" "adopted" { + name = "my-channel02" + topic = "Adopt existing, don't kick members" + permanent_members = [] + adopt_existing_channel = true + action_on_update_permanent_members = "none" +} +``` + ## Argument Reference The following arguments are supported: @@ -72,6 +98,11 @@ name will fail. whether the members should be kick of the channel when removed from `permanent_members`. When set to `none` the user are never kicked, this prevent a side effect on public channels where user that joined the channel are kicked. +- `adopt_existing_channel` (Optional, Default `false`) indicates that an +existing channel with the same name should be adopted by terraform and put under +state management. If the existing channel is archived, it will be unarchived. +(Note: for unarchiving of existing channels to work correctly, you_must_ use +a user token, not a bot token, due to bugs in the Slack API) ## Attribute Reference diff --git a/go.mod b/go.mod index 1b868ec..e9cad82 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/bflad/tfproviderdocs v0.9.1 github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 + github.com/hashicorp/terraform-plugin-log v0.7.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.20.0 github.com/katbyte/terrafmt v0.5.2 github.com/mitchellh/cli v1.1.2 // indirect diff --git a/go.sum b/go.sum index b2c0a39..b76c541 100644 --- a/go.sum +++ b/go.sum @@ -146,6 +146,7 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= @@ -316,6 +317,7 @@ github.com/hashicorp/hc-install v0.4.0 h1:cZkRFr1WVa0Ty6x5fTvL1TuO1flul231rWkGH9 github.com/hashicorp/hc-install v0.4.0/go.mod h1:5d155H8EC5ewegao9A4PUTMNPZaq+TbOzkJJZ4vrXeI= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/hcl/v2 v2.6.0/go.mod h1:bQTN5mpo+jewjJgh8jr0JUguIi7qPHUF6yIfAEN3jqY= github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc= github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= @@ -327,8 +329,11 @@ github.com/hashicorp/serf v0.9.7/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpT github.com/hashicorp/terraform-exec v0.17.2 h1:EU7i3Fh7vDUI9nNRdMATCEfnm9axzTnad8zszYZ73Go= github.com/hashicorp/terraform-exec v0.17.2/go.mod h1:tuIbsL2l4MlwwIZx9HPM+LOV9vVyEfBYu2GsO1uH3/8= github.com/hashicorp/terraform-json v0.5.0/go.mod h1:eAbqb4w0pSlRmdvl8fOyHAi/+8jnkVYN28gJkSJrLhU= +github.com/hashicorp/terraform-json v0.8.0/go.mod h1:3defM4kkMfttwiE7VakJDwCd4R+umhSQnvJwORXbprE= github.com/hashicorp/terraform-json v0.14.0 h1:sh9iZ1Y8IFJLx+xQiKHGud6/TSUCM0N8e17dKDpqV7s= github.com/hashicorp/terraform-json v0.14.0/go.mod h1:5A9HIWPkk4e5aeeXIBbkcOvaZbIYnAIkEyqP2pNSckM= +github.com/hashicorp/terraform-plugin-go v0.9.1 h1:vXdHaQ6aqL+OF076nMSBV+JKPdmXlzG5mzVDD04WyPs= +github.com/hashicorp/terraform-plugin-go v0.9.1/go.mod h1:ItjVSlQs70otlzcCwlPcU8FRXLdO973oYFRZwAOxy8M= github.com/hashicorp/terraform-plugin-go v0.12.0 h1:6wW9mT1dSs0Xq4LR6HXj1heQ5ovr5GxXNJwkErZzpJw= github.com/hashicorp/terraform-plugin-go v0.12.0/go.mod h1:kwhmaWHNDvT1B3QiSJdAtrB/D4RaKSY/v3r2BuoWK4M= github.com/hashicorp/terraform-plugin-log v0.6.0/go.mod h1:p4R1jWBXRTvL4odmEkFfDdhUjHf9zcs/BCoNHAc7IK4= @@ -397,6 +402,7 @@ github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamh github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -404,6 +410,7 @@ github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZb github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= @@ -560,6 +567,7 @@ github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLE github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= github.com/zclconf/go-cty v1.2.1/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= +github.com/zclconf/go-cty v1.9.1/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty v1.10.0 h1:mp9ZXQeIcN8kAwuqorjH+Q+njbJKjLrvB2yIh4q7U+0= github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= @@ -807,6 +815,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/slack/resource_conversation.go b/slack/resource_conversation.go index e7ad3fc..47c8a57 100644 --- a/slack/resource_conversation.go +++ b/slack/resource_conversation.go @@ -3,7 +3,9 @@ package slack import ( "context" "fmt" + "time" + "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" @@ -16,6 +18,10 @@ const ( conversationActionOnUpdatePermanentMembersNone = "none" conversationActionOnUpdatePermanentMembersKick = "kick" + + // 100 is default, slack docs recommend no more than 200, but 1000 is the max. + // See also https://github.com/slack-go/slack/blob/master/users.go#L305 + cursorLimit = 200 ) var ( @@ -112,6 +118,11 @@ func resourceSlackConversation() *schema.Resource { Default: "kick", ValidateFunc: validateConversationActionOnUpdatePermanentMembers, }, + "adopt_existing_channel": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, }, } } @@ -123,6 +134,17 @@ func resourceSlackConversationCreate(ctx context.Context, d *schema.ResourceData isPrivate := d.Get("is_private").(bool) channel, err := client.CreateConversationContext(ctx, name, isPrivate) + if err != nil && err.Error() == "name_taken" && d.Get("adopt_existing_channel").(bool) { + channel, err = findExistingChannel(ctx, client, name, isPrivate) + if err == nil && channel.IsArchived { + // ensure unarchived first if adopting existing channel, else other calls below will fail + if err := client.UnArchiveConversationContext(ctx, channel.ID); err != nil { + if err.Error() != "not_archived" { + return diag.Errorf("couldn't unarchive conversation %s: %s", channel.ID, err) + } + } + } + } if err != nil { return diag.Errorf("could not create conversation %s: %s", name, err) } @@ -157,6 +179,58 @@ func resourceSlackConversationCreate(ctx context.Context, d *schema.ResourceData return resourceSlackConversationRead(ctx, d, m) } +func findExistingChannel(ctx context.Context, client *slack.Client, name string, isPrivate bool) (*slack.Channel, error) { + // find the existing channel. Sadly, there is no non-admin API to search by name, + // so we must search through ALL the channels + tflog.Info(ctx, "Looking for channel %s", map[string]interface{}{"channel": name}) + paginationComplete := false + cursor := "" // initial empty cursor to begin at start of list + var types []string // default value with empty list is "public_channel" + if isPrivate { + types = append(types, "private_channel") + } + for !paginationComplete { + channels, nextCursor, err := client.GetConversationsContext(ctx, &slack.GetConversationsParameters{ + Cursor: cursor, + Limit: cursorLimit, + Types: types, + }) + tflog.Debug(ctx, "new page of channels", + map[string]interface{}{ + "numChannels": len(channels), + "nextCursor": nextCursor, + "err": err}) + if err != nil { + if rateLimitedError, ok := err.(*slack.RateLimitedError); ok { + tflog.Warn(ctx, "rate limited", map[string]interface{}{"seconds": rateLimitedError.RetryAfter.Seconds()}) + select { + case <-ctx.Done(): + return nil, fmt.Errorf("canceled during pagination: %s", ctx.Err()) + case <-time.After(rateLimitedError.RetryAfter): + tflog.Debug(ctx, "done sleeping after rate limited") + } + // retry current cursor + } else { + return nil, fmt.Errorf("name_taken, but %s trying to find", err) + } + } else { + // see if channel in current batch + for _, c := range channels { + tflog.Trace(ctx, "checking channel", map[string]interface{}{"channel": c.Name}) + if c.Name == name { + tflog.Info(ctx, "found channel") + return &c, nil + } + } + // not found so far, move on to next cursor, if pagination incomplete + paginationComplete = nextCursor == "" + cursor = nextCursor + } + } + // looked through entire list, but didn't find matching name + return nil, fmt.Errorf("name_taken, but could not find channel") +} + func updateChannelMembers(ctx context.Context, d *schema.ResourceData, client *slack.Client, channelID string) error { members := d.Get("permanent_members").(*schema.Set) @@ -182,6 +256,13 @@ func updateChannelMembers(ctx context.Context, d *schema.ResourceData, client *s return fmt.Errorf("could not retrieve conversation users for ID %s: %w", channelID, err) } + // first, ensure the api user is in the channel, otherwise other member modifications below may fail + if _, _, _, err := client.JoinConversationContext(ctx, channelID); err != nil { + if err.Error() != "already_in_channel" && err.Error() != "method_not_supported_for_channel_type" { + return fmt.Errorf("api user could not join conversation: %w", err) + } + } + action := d.Get("action_on_update_permanent_members").(string) if action == conversationActionOnUpdatePermanentMembersKick { for _, currentMember := range channelUsers { @@ -265,7 +346,7 @@ func resourceSlackConversationUpdate(ctx context.Context, d *schema.ResourceData } else { if err := client.UnArchiveConversationContext(ctx, id); err != nil { if err.Error() != "not_archived" { - return diag.Errorf("couldn't archive conversation %s: %s", id, err) + return diag.Errorf("couldn't unarchive conversation %s: %s", id, err) } } } diff --git a/slack/resource_conversation_test.go b/slack/resource_conversation_test.go index dd61dcd..12a61c2 100644 --- a/slack/resource_conversation_test.go +++ b/slack/resource_conversation_test.go @@ -144,7 +144,7 @@ func testSlackConversationUpdate(t *testing.T, resourceName string, createChanne ResourceName: resourceName, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"permanent_members", "action_on_destroy", "action_on_update_permanent_members"}, + ImportStateVerifyIgnore: []string{"permanent_members", "action_on_destroy", "action_on_update_permanent_members", "adopt_existing_channel"}, }, }