From 93551b91e940f882d14543e4f62950218369c1b2 Mon Sep 17 00:00:00 2001 From: atoni <50518795+bosc0@users.noreply.github.com> Date: Thu, 16 Jan 2025 15:50:41 +0100 Subject: [PATCH] feat: implement usergroup resource --- internal/provider/provider.go | 2 +- internal/provider/resource_usergroup.go | 369 ++++++++++++++++++++++++ internal/slackExt/client.go | 6 + internal/slackExt/client_impl.go | 20 ++ internal/slackExt/client_rate_limit.go | 30 ++ 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 internal/provider/resource_usergroup.go diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 43c565e..34ddb9b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -91,7 +91,7 @@ func (p *SlackProvider) Configure(ctx context.Context, req provider.ConfigureReq func (p *SlackProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ - // Add your resources here if needed + NewUserGroupResource, } } diff --git a/internal/provider/resource_usergroup.go b/internal/provider/resource_usergroup.go new file mode 100644 index 0000000..d96135e --- /dev/null +++ b/internal/provider/resource_usergroup.go @@ -0,0 +1,369 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/essent/terraform-provider-slack/internal/slackExt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/types" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/slack-go/slack" +) + +var ( + _ resource.Resource = &UserGroupResource{} + _ resource.ResourceWithImportState = &UserGroupResource{} +) + +func NewUserGroupResource() resource.Resource { + return &UserGroupResource{} +} + +type UserGroupResource struct { + client slackExt.Client +} + +type UserGroupResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Handle types.String `tfsdk:"handle"` + Channels types.List `tfsdk:"channels"` + Users types.List `tfsdk:"users"` +} + +func (r *UserGroupResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_usergroup" +} + +func (r *UserGroupResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Manages a Slack user group.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + "handle": schema.StringAttribute{ + Optional: true, + Computed: true, + }, + "channels": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + Computed: true, + Description: "Channels shared by the user group.", + }, + "users": schema.ListAttribute{ + ElementType: types.StringType, + Optional: true, + Computed: true, + MarkdownDescription: "List of user IDs in the user group.", + }, + }, + } +} + +func (r *UserGroupResource) Configure( + _ context.Context, + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + pd, ok := req.ProviderData.(*SlackProviderData) + if !ok || pd.Client == nil { + resp.Diagnostics.AddError("Invalid Provider Data", "Could not create Slack client.") + return + } + r.client = pd.Client +} + +func (r *UserGroupResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan UserGroupResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + if plan.Handle.IsNull() || plan.Handle.ValueString() == "" { + plan.Handle = plan.Name + } + + channels := listToStringSlice(plan.Channels) + users := listToStringSlice(plan.Users) + + createReq := slack.UserGroup{ + Name: plan.Name.ValueString(), + Description: plan.Description.ValueString(), + Handle: plan.Handle.ValueString(), + Prefs: slack.UserGroupPrefs{ + Channels: channels, + }, + } + + created, err := r.client.CreateUserGroup(ctx, createReq) + if err != nil { + if err.Error() == "name_already_exists" || err.Error() == "handle_already_exists" { + existingGroup, err2 := findGroupByName(ctx, plan.Name.ValueString(), true, r.client) + if err2 != nil { + resp.Diagnostics.AddError("Create Error", fmt.Sprintf("Could not find existing group: %s", err2)) + return + } + _, err2 = r.client.EnableUserGroup(ctx, existingGroup.ID) + if err2 != nil && err2.Error() != "already_enabled" { + resp.Diagnostics.AddError("Enable Error", fmt.Sprintf("Could not enable usergroup %s: %s", existingGroup.ID, err2)) + return + } + _, err2 = r.client.UpdateUserGroup(ctx, existingGroup.ID) + if err2 != nil { + resp.Diagnostics.AddError("Update Error", fmt.Sprintf("Could not update usergroup %s: %s", existingGroup.ID, err2)) + return + } + plan.ID = types.StringValue(existingGroup.ID) + } else { + resp.Diagnostics.AddError("Create Error", fmt.Sprintf("Error creating usergroup: %s", err)) + return + } + } else { + plan.ID = types.StringValue(created.ID) + } + + if len(users) > 0 { + _, err := r.client.UpdateUserGroupMembers(ctx, plan.ID.ValueString(), strings.Join(users, ",")) + if err != nil { + resp.Diagnostics.AddError("Members Update Error", fmt.Sprintf("Could not update usergroup members: %s", err)) + return + } + } + + if err := r.readIntoModel(ctx, &plan); err != nil { + resp.Diagnostics.AddError("Read Error", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *UserGroupResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state UserGroupResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + groups, err := r.client.GetUserGroups(ctx, slack.GetUserGroupsOptionIncludeUsers(true)) + if err != nil { + resp.Diagnostics.AddError("Read Error", fmt.Sprintf("Could not retrieve user groups: %s", err)) + return + } + + found := findGroupByID(groups, state.ID.ValueString()) + if found == nil { + tflog.Warn(ctx, "User group not found in Slack; removing from state", map[string]interface{}{ + "id": state.ID.ValueString(), + }) + resp.State.RemoveResource(ctx) + return + } + + setStateFromUserGroup(found, &state) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *UserGroupResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan, state UserGroupResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + if plan.Handle.IsNull() || plan.Handle.ValueString() == "" { + plan.Handle = plan.Name + } + + channels := listToStringSlice(plan.Channels) + users := listToStringSlice(plan.Users) + + opts := []slack.UpdateUserGroupsOption{ + slack.UpdateUserGroupsOptionName(plan.Name.ValueString()), + slack.UpdateUserGroupsOptionHandle(plan.Handle.ValueString()), + slack.UpdateUserGroupsOptionDescription(&[]string{plan.Description.ValueString()}[0]), + slack.UpdateUserGroupsOptionChannels(channels), + } + + _, err := r.client.UpdateUserGroup(ctx, state.ID.ValueString(), opts...) + if err != nil { + resp.Diagnostics.AddError("Update Error", fmt.Sprintf("Could not update usergroup: %s", err)) + return + } + + if !plan.Users.Equal(state.Users) && len(users) > 0 { + _, err = r.client.UpdateUserGroupMembers(ctx, state.ID.ValueString(), strings.Join(users, ",")) + if err != nil { + resp.Diagnostics.AddError("Members Update Error", fmt.Sprintf("Could not update usergroup members: %s", err)) + return + } + } + + if err := r.readIntoModel(ctx, &plan); err != nil { + resp.Diagnostics.AddError("Read Error", err.Error()) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *UserGroupResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state UserGroupResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.DisableUserGroup(ctx, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Delete Error", fmt.Sprintf("Could not disable usergroup: %s", err)) + return + } + resp.State.RemoveResource(ctx) +} + +func (r *UserGroupResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func (r *UserGroupResource) readIntoModel(ctx context.Context, model *UserGroupResourceModel) error { + groups, err := r.client.GetUserGroups(ctx, slack.GetUserGroupsOptionIncludeUsers(true)) + if err != nil { + return fmt.Errorf("could not read user group: %w", err) + } + found := findGroupByID(groups, model.ID.ValueString()) + if found == nil { + tflog.Warn(ctx, "User group not found after create/update", map[string]interface{}{ + "id": model.ID.ValueString(), + }) + return nil + } + setStateFromUserGroup(found, model) + return nil +} + +func listToStringSlice(l types.List) []string { + if l.IsNull() || l.IsUnknown() { + return nil + } + elems := l.Elements() + result := make([]string, 0, len(elems)) + for _, e := range elems { + if s, ok := e.(types.String); ok && !s.IsNull() && !s.IsUnknown() { + result = append(result, s.ValueString()) + } + } + return result +} + +func stringSliceToList(list []string) types.List { + if len(list) == 0 { + emptyVal, _ := types.ListValue(types.StringType, []attr.Value{}) + return emptyVal + } + + attrValues := make([]attr.Value, len(list)) + for i, s := range list { + attrValues[i] = types.StringValue(s) + } + res, diags := types.ListValue(types.StringType, attrValues) + if diags.HasError() { + return types.ListNull(types.StringType) + } + return res +} + +func findGroupByID(groups []slack.UserGroup, id string) *slack.UserGroup { + for i := range groups { + if groups[i].ID == id { + return &groups[i] + } + } + return nil +} + +func findGroupByName( + ctx context.Context, + name string, + includeDisabled bool, + client slackExt.Client, +) (slack.UserGroup, error) { + groups, err := client.GetUserGroups(ctx, + slack.GetUserGroupsOptionIncludeDisabled(includeDisabled), + slack.GetUserGroupsOptionIncludeUsers(true), + ) + if err != nil { + return slack.UserGroup{}, err + } + for _, g := range groups { + if g.Name == name { + return g, nil + } + } + return slack.UserGroup{}, fmt.Errorf("no usergroup with name %q found", name) +} + +func setStateFromUserGroup(ug *slack.UserGroup, model *UserGroupResourceModel) { + model.ID = types.StringValue(ug.ID) + model.Name = types.StringValue(ug.Name) + model.Description = types.StringValue(ug.Description) + model.Handle = types.StringValue(ug.Handle) + model.Channels = stringSliceToList(ug.Prefs.Channels) + model.Users = stringSliceToList(ug.Users) +} diff --git a/internal/slackExt/client.go b/internal/slackExt/client.go index 596a5f2..7750459 100644 --- a/internal/slackExt/client.go +++ b/internal/slackExt/client.go @@ -15,6 +15,12 @@ type Client interface { GetUsersContext(ctx context.Context) ([]slack.User, error) GetUserGroups(ctx context.Context, options ...slack.GetUserGroupsOption) ([]slack.UserGroup, error) GetConversationInfo(ctx context.Context, input *slack.GetConversationInfoInput) (*slack.Channel, error) + + CreateUserGroup(ctx context.Context, userGroup slack.UserGroup) (slack.UserGroup, error) + DisableUserGroup(ctx context.Context, userGroup string) (slack.UserGroup, error) + EnableUserGroup(ctx context.Context, userGroup string) (slack.UserGroup, error) + UpdateUserGroup(ctx context.Context, userGroupID string, options ...slack.UpdateUserGroupsOption) (slack.UserGroup, error) + UpdateUserGroupMembers(ctx context.Context, userGroup string, members string) (slack.UserGroup, error) } func New(base *slack.Client) Client { diff --git a/internal/slackExt/client_impl.go b/internal/slackExt/client_impl.go index 160e4b0..7de6880 100644 --- a/internal/slackExt/client_impl.go +++ b/internal/slackExt/client_impl.go @@ -32,3 +32,23 @@ func (c *clientImpl) GetUserGroups(ctx context.Context, options ...slack.GetUser func (c *clientImpl) GetConversationInfo(ctx context.Context, input *slack.GetConversationInfoInput) (*slack.Channel, error) { return c.base.GetConversationInfoContext(ctx, input) } + +func (c *clientImpl) CreateUserGroup(ctx context.Context, userGroup slack.UserGroup) (slack.UserGroup, error) { + return c.base.CreateUserGroupContext(ctx, userGroup) +} + +func (c *clientImpl) DisableUserGroup(ctx context.Context, userGroup string) (slack.UserGroup, error) { + return c.base.DisableUserGroupContext(ctx, userGroup) +} + +func (c *clientImpl) EnableUserGroup(ctx context.Context, userGroup string) (slack.UserGroup, error) { + return c.base.EnableUserGroupContext(ctx, userGroup) +} + +func (c *clientImpl) UpdateUserGroup(ctx context.Context, userGroupID string, options ...slack.UpdateUserGroupsOption) (slack.UserGroup, error) { + return c.base.UpdateUserGroupContext(ctx, userGroupID, options...) +} + +func (c *clientImpl) UpdateUserGroupMembers(ctx context.Context, userGroup string, members string) (slack.UserGroup, error) { + return c.base.UpdateUserGroupMembersContext(ctx, userGroup, members) +} diff --git a/internal/slackExt/client_rate_limit.go b/internal/slackExt/client_rate_limit.go index 251872a..c9cd6a6 100644 --- a/internal/slackExt/client_rate_limit.go +++ b/internal/slackExt/client_rate_limit.go @@ -60,3 +60,33 @@ func (c *clientRateLimit) GetConversationInfo(ctx context.Context, input *slack. return c.base.GetConversationInfo(ctx, input) }, func() *slack.Channel { return nil }) } + +func (c *clientRateLimit) CreateUserGroup(ctx context.Context, userGroup slack.UserGroup) (slack.UserGroup, error) { + return rateLimit(ctx, func() (slack.UserGroup, error) { + return c.base.CreateUserGroup(ctx, userGroup) + }, func() slack.UserGroup { return slack.UserGroup{} }) +} + +func (c *clientRateLimit) DisableUserGroup(ctx context.Context, userGroup string) (slack.UserGroup, error) { + return rateLimit(ctx, func() (slack.UserGroup, error) { + return c.base.DisableUserGroup(ctx, userGroup) + }, func() slack.UserGroup { return slack.UserGroup{} }) +} + +func (c *clientRateLimit) EnableUserGroup(ctx context.Context, userGroup string) (slack.UserGroup, error) { + return rateLimit(ctx, func() (slack.UserGroup, error) { + return c.base.EnableUserGroup(ctx, userGroup) + }, func() slack.UserGroup { return slack.UserGroup{} }) +} + +func (c *clientRateLimit) UpdateUserGroup(ctx context.Context, userGroupID string, options ...slack.UpdateUserGroupsOption) (slack.UserGroup, error) { + return rateLimit(ctx, func() (slack.UserGroup, error) { + return c.base.UpdateUserGroup(ctx, userGroupID, options...) + }, func() slack.UserGroup { return slack.UserGroup{} }) +} + +func (c *clientRateLimit) UpdateUserGroupMembers(ctx context.Context, userGroup string, members string) (slack.UserGroup, error) { + return rateLimit(ctx, func() (slack.UserGroup, error) { + return c.base.UpdateUserGroupMembers(ctx, userGroup, members) + }, func() slack.UserGroup { return slack.UserGroup{} }) +}