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"}, }, }